- Vue.js
Vue shallowRef・triggerRefの使い方|refとの違い
shallowRefとは、.value の差し替えだけを検知し、中身のオブジェクトはリアクティブにしないrefです。triggerRefは、shallowRefの中身を直接書き換えたあとに「更新があった」と手動で知らせる関数です。 通常の ref は中に入れたオブジェクトを奥の階層までリアクティブにしますが、shallowRef はそれをしない分、大きなデータを軽く扱えます。
この記事では、10万件の配列を ref と shallowRef に入れたときの生成・読み取り時間を計測して数値で比べます。あわせて、ネストした値を書き換えても画面が更新されない現象と2つの直し方、shallowReactive・markRaw・toRaw との違い、チャートや地図などライブラリのインスタンスを持たせる書き方を、すべて実行結果つきでまとめました。
確認環境:Ubuntu 24.04 / Node.js 22.22.2 / Vue 3.5.43 / jsdom 30.1.1。コードは .mjs ファイルとして Node.js で実行し、画面の内容を確かめる例は jsdom 上にコンポーネントをマウントしています(描画は h() の描画関数で書いています)。計測は Intel Xeon 2.10GHz・2コアの仮想マシンで行いました。
shallowRef・triggerRefの早見表
| やりたいこと | 書き方 | ポイント |
|---|---|---|
| 大きな配列やオブジェクトを軽く持つ | const rows = shallowRef([]) |
中身はリアクティブにならない |
| 中身を更新して画面に反映する(差し替え) | rows.value = [...rows.value, item] |
新しい配列・オブジェクトを代入する |
| 中身を直接書き換えて反映する | rows.value.push(item) → triggerRef(rows) |
書き換えの後に必ず呼ぶ |
| ライブラリのインスタンスを持つ | const chart = shallowRef(null) |
インスタンスをプロキシで包まない |
| reactive の中にインスタンスを置く | reactive({ chart: markRaw(instance) }) |
そのオブジェクトだけ対象から外す |
| 1階層目だけリアクティブなオブジェクト | shallowReactive({ page: 1, filter: {} }) |
.value が要らない |
| リアクティブなデータの元のオブジェクトを得る | toRaw(state) |
大量の読み取りを軽くしたいときに |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Vue shallowRef・triggerRefの使い方|refとの違い」
ref・shallowRef・shallowReactive・markRaw・toRawの比較表
| API | 何をするか | リアクティブになる範囲 | .value |
主な用途 |
|---|---|---|---|---|
ref(obj) |
中身を奥までリアクティブにする | すべての階層 | 必要 | 通常の状態 |
shallowRef(obj) |
.value の差し替えだけを検知する |
.value のみ |
必要 | 大きなデータ、ライブラリのインスタンス |
triggerRef(r) |
shallowRef に依存する処理を手動で動かす | ― | ― | 中身を直接書き換えた後 |
reactive(obj) |
オブジェクトを奥までリアクティブにする | すべての階層 | 不要 | まとまった状態 |
shallowReactive(obj) |
1階層目だけリアクティブにする | 1階層目のプロパティ | 不要 | 中身を差し替えで更新する状態 |
markRaw(obj) |
そのオブジェクトをリアクティブ化の対象から外す | なし | ― | reactive の中に置くインスタンス |
toRaw(proxy) |
プロキシから元のオブジェクトを取り出す | ― | ― | 一時的な読み取り、外部への受け渡し |
refとshallowRefの違いを実行結果で見る
ネストした値を書き換えても更新されない
コードは次の準備で動かせます。
mkdir shallowref-demo && cd shallowref-demo
npm init -y
npm install vue jsdom
node demo.mjs # コードを demo.mjs に保存して実行
同じ内容のオブジェクトを ref と shallowRef に入れ、同じ操作をしたときの画面の内容を比べます。
import { JSDOM } from 'jsdom'
const dom = new JSDOM('<div id="app1"></div><div id="app2"></div>')
globalThis.window = dom.window
globalThis.document = dom.window.document
globalThis.Element = dom.window.Element
globalThis.SVGElement = dom.window.SVGElement
const { createApp, ref, shallowRef, triggerRef, h, nextTick } = await import('vue')
const deep = ref({ user: { name: 'sato' }, tags: ['vue'] })
const shallow = shallowRef({ user: { name: 'sato' }, tags: ['vue'] })
// 片方の再描画がもう片方に影響しないよう、別々のアプリとして表示する
createApp({
render: () => h('p', { id: 'deep' }, `${deep.value.user.name} / ${deep.value.tags.join(',')}`),
}).mount('#app1')
createApp({
render: () => h('p', { id: 'shallow' }, `${shallow.value.user.name} / ${shallow.value.tags.join(',')}`),
}).mount('#app2')
const show = (label) => console.log(
`${label}\n ref : ${document.querySelector('#deep').textContent}\n shallowRef: ${document.querySelector('#shallow').textContent}`)
show('最初の表示')
deep.value.user.name = 'suzuki'
shallow.value.user.name = 'suzuki'
await nextTick()
show('(1) ネストした値を書き換え')
console.log(' shallowRef の中身:', JSON.stringify(shallow.value))
deep.value.tags.push('pinia')
shallow.value.tags.push('pinia')
await nextTick()
show('(2) 配列に push')
triggerRef(shallow)
await nextTick()
show('(3) triggerRef(shallow) を呼ぶ')
shallow.value = { ...shallow.value, user: { name: 'tanaka' } }
await nextTick()
show('(4) .value を新しいオブジェクトに差し替え')
shallow.value.user.name = 'ito'
shallow.value = shallow.value // 同じオブジェクトを代入しても変更とみなされない
await nextTick()
show('(5) 同じオブジェクトを代入し直す')
実行結果:
最初の表示
ref : sato / vue
shallowRef: sato / vue
(1) ネストした値を書き換え
ref : suzuki / vue
shallowRef: sato / vue
shallowRef の中身: {"user":{"name":"suzuki"},"tags":["vue"]}
(2) 配列に push
ref : suzuki / vue,pinia
shallowRef: sato / vue
(3) triggerRef(shallow) を呼ぶ
ref : suzuki / vue,pinia
shallowRef: suzuki / vue,pinia
(4) .value を新しいオブジェクトに差し替え
ref : suzuki / vue,pinia
shallowRef: tanaka / vue,pinia
(5) 同じオブジェクトを代入し直す
ref : suzuki / vue,pinia
shallowRef: tanaka / vue,pinia
- (1)(2) ネストした値の書き換えや
pushは、shallowRefでは画面に反映されません。 データ自体は書き換わっています(shallowRef の中身の行)が、Vue がその変更を検知していないため、再描画が起きません。 - (3)
triggerRef(shallow)を呼ぶと、それまでの書き換えがまとめて画面に反映されます。 - (4)
.valueに新しいオブジェクトを代入すると、反映されます。 - (5) 同じオブジェクトを代入し直しても、変更とはみなされません。 中身は
itoに書き換わっていますが、画面はtanakaのままです。
直し方は2つ:差し替えるか、triggerRefを呼ぶか
| 直し方 | 書き方 | 向く場面 |
|---|---|---|
.value を丸ごと差し替える |
list.value = [...list.value, item] |
基本はこちら。変更が代入として見えるので読みやすい |
書き換えてから triggerRef を呼ぶ |
list.value.push(item) → triggerRef(list) |
大きな配列をコピーしたくないとき、まとめて多数を書き換えるとき |
配列を例に、4つの書き方を続けて試します。shallowReactive と、watch で監視したときの動きも同じファイルで確かめます。
import { shallowReactive, shallowRef, triggerRef, computed, watch, watchEffect, nextTick } from 'vue'
console.log('=== shallowReactive ===')
const state = shallowReactive({ page: 1, filter: { keyword: '' } })
watchEffect(() => console.log(`page=${state.page} keyword=${state.filter.keyword}`))
state.page = 2
await nextTick()
state.filter.keyword = 'vue' // 2階層目: 反応しない
await nextTick()
console.log('(keyword を書き換えても動かない)')
state.filter = { keyword: 'vue' } // 1階層目を差し替え: 反応する
await nextTick()
console.log('=== shallowRef の配列を更新する書き方 ===')
const list = shallowRef([{ id: 1, done: false }])
const doneCount = computed(() => list.value.filter((t) => t.done).length)
watchEffect(() => console.log(`件数=${list.value.length} 完了=${doneCount.value}`))
list.value.push({ id: 2, done: false })
await nextTick()
console.log('(A) push だけ: 動かない')
list.value = [...list.value, { id: 3, done: false }]
await nextTick()
console.log('(B) 新しい配列に差し替え: 動く')
list.value[0].done = true
triggerRef(list)
await nextTick()
console.log('(C) 中身を書き換えて triggerRef: 動く')
list.value = list.value.map((t) => (t.id === 2 ? { ...t, done: true } : t))
await nextTick()
console.log('(D) map で新しい配列を作る: 動く')
console.log('=== watch と shallowRef ===')
const conf = shallowRef({ size: 10 })
watch(conf, (v) => console.log('watch(conf):', v.size))
watch(conf, (v) => console.log('watch(conf, deep):', v.size), { deep: true })
conf.value.size = 20
await nextTick()
console.log('(中身の書き換えは deep: true でも検知しない)')
triggerRef(conf)
await nextTick()
conf.value = { size: 30 }
await nextTick()
実行結果:
=== shallowReactive ===
page=1 keyword=
page=2 keyword=
(keyword を書き換えても動かない)
page=2 keyword=vue
=== shallowRef の配列を更新する書き方 ===
件数=1 完了=0
(A) push だけ: 動かない
件数=3 完了=0
(B) 新しい配列に差し替え: 動く
件数=3 完了=1
(C) 中身を書き換えて triggerRef: 動く
件数=3 完了=2
(D) map で新しい配列を作る: 動く
=== watch と shallowRef ===
(中身の書き換えは deep: true でも検知しない)
watch(conf): 20
watch(conf, deep): 20
watch(conf): 30
watch(conf, deep): 30
pushだけでは動きません(A)。 新しい配列への差し替え(B・D)か、書き換え後のtriggerRef(C)で動きます。computed(doneCount)も、差し替えかtriggerRefをきっかけに再計算されます。shallowReactiveは1階層目のプロパティの代入だけを検知します。state.filter.keywordの書き換えは検知されず、state.filterを差し替えると動きます。shallowRefをwatchで監視する場合、中身の書き換えはdeep: trueを付けても検知されません。triggerRefを呼ぶか.valueを差し替えると、どちらのウォッチャーも動きます。
10万件の配列で計測する:refとshallowRefの速度差
計測コード
1件が5つのプロパティ(うち1つは配列、1つはオブジェクト)を持つデータを10万件作り、ref と shallowRef に入れて、生成にかかる時間と、全件を読んで合計する時間を計ります。7回繰り返した中央値を表示します。
import { ref, shallowRef, isReactive } from 'vue'
const N = 100_000
const makeRows = () =>
Array.from({ length: N }, (_, i) => ({ id: i, name: `item${i}`, price: i % 1000, tags: ['a', 'b'], meta: { stock: i % 50 } }))
const ms = (fn) => { const t = performance.now(); const r = fn(); return [performance.now() - t, r] }
const median = (xs) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)]
const sum = (rows) => { let s = 0; for (let i = 0; i < rows.length; i++) s += rows[i].price + rows[i].meta.stock; return s }
const result = { ref: { create: [], read1: [], read2: [] }, shallowRef: { create: [], read1: [], read2: [] }, plain: { read1: [] } }
for (let round = 0; round < 7; round++) {
for (const [name, factory] of [['ref', ref], ['shallowRef', shallowRef]]) {
const rows = makeRows()
const [tc, box] = ms(() => factory(rows))
const [t1] = ms(() => sum(box.value)) // 1回目の読み取り
const [t2] = ms(() => sum(box.value)) // 2回目の読み取り
result[name].create.push(tc); result[name].read1.push(t1); result[name].read2.push(t2)
}
const rows = makeRows()
result.plain.read1.push(ms(() => sum(rows))[0])
}
const f = (xs) => median(xs).toFixed(2).padStart(8)
console.log(`件数: ${N} / 7回の中央値 (ms)`)
console.log(' 生成 読み取り1回目 読み取り2回目')
console.log(`ref ${f(result.ref.create)} ${f(result.ref.read1)} ${f(result.ref.read2)}`)
console.log(`shallowRef${f(result.shallowRef.create)} ${f(result.shallowRef.read1)} ${f(result.shallowRef.read2)}`)
console.log(`素の配列 - ${f(result.plain.read1)} -`)
const a = ref(makeRows()), b = shallowRef(makeRows())
console.log('isReactive(ref.value) :', isReactive(a.value), '/ 要素:', isReactive(a.value[0]), '/ 要素.meta:', isReactive(a.value[0].meta))
console.log('isReactive(shallowRef.value) :', isReactive(b.value), '/ 要素:', isReactive(b.value[0]))
実行結果:
件数: 100000 / 7回の中央値 (ms)
生成 読み取り1回目 読み取り2回目
ref 0.03 161.07 109.97
shallowRef 0.01 3.43 3.65
素の配列 - 3.46 -
isReactive(ref.value) : true / 要素: true / 要素.meta: true
isReactive(shallowRef.value) : false / 要素: false
次に、実際のコンポーネントの描画に近い条件として、watchEffect の中で全件を読む時間を計ります。この場合は依存の記録も行われます。ref の中身を toRaw で取り出して読む方法も並べます。
import { ref, shallowRef, toRaw, watchEffect } from 'vue'
const N = 100_000
const makeRows = () =>
Array.from({ length: N }, (_, i) => ({ id: i, name: `item${i}`, price: i % 1000, tags: ['a', 'b'], meta: { stock: i % 50 } }))
const sum = (rows) => { let s = 0; for (let i = 0; i < rows.length; i++) s += rows[i].price + rows[i].meta.stock; return s }
const median = (xs) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)]
const times = { ref: [], 'ref + toRaw': [], shallowRef: [] }
for (let round = 0; round < 7; round++) {
const cases = {
ref: [ref(makeRows()), (box) => sum(box.value)],
'ref + toRaw': [ref(makeRows()), (box) => sum(toRaw(box.value))],
shallowRef: [shallowRef(makeRows()), (box) => sum(box.value)],
}
for (const [name, [box, read]] of Object.entries(cases)) {
const t = performance.now()
const stop = watchEffect(() => read(box), { flush: 'sync' }) // 作成と同時に1回実行される
times[name].push(performance.now() - t)
stop()
}
}
console.log(`watchEffect の中で ${N} 件を合計する時間(7回の中央値)`)
for (const [name, xs] of Object.entries(times)) console.log(`${name.padEnd(12)} ${median(xs).toFixed(2).padStart(8)} ms`)
実行結果:
watchEffect の中で 100000 件を合計する時間(7回の中央値)
ref 538.82 ms
ref + toRaw 3.90 ms
shallowRef 3.81 ms
計測結果の表
| 計測内容 | ref |
shallowRef |
差 |
|---|---|---|---|
| 生成(10万件の配列を入れる) | 0.03 ms | 0.01 ms | どちらも一瞬 |
| 読み取り1回目(全件を合計) | 161.07 ms | 3.43 ms | 約47倍 |
| 読み取り2回目 | 109.97 ms | 3.65 ms | 約30倍 |
watchEffect の中で読み取り |
538.82 ms | 3.81 ms | 約141倍 |
ref + toRaw で読み取り |
3.90 ms | ― | shallowRef とほぼ同じ |
| 参考:素の配列を読み取り | 3.46 ms | ― | ― |
数値は環境によって変わりますが、傾向として次のことが読み取れます。
- 生成の時間はどちらもごくわずかです。
refは入れた時点で全件を変換するのではなく、読まれたオブジェクトから順にリアクティブにしていくためです。 - 差が出るのは読み取りです。
refは要素やネストしたオブジェクトを読むたびにプロキシを経由し、1回目はプロキシの作成も加わります。shallowRefは素の配列を読むのとほぼ同じ時間です。 watchEffectの中では差がさらに広がります。 読んだプロパティを1つずつ依存として記録するためです。コンポーネントの描画も同じ仕組みで依存を記録するので、大きなリストを描画する場面に近い条件といえます。refのままでも、toRawで元の配列を取り出して読めばshallowRefと同程度になります。 ただしtoRawで読んだ値は依存として記録されないため、中身の変更では再実行されません。
Vue の公式ガイドのパフォーマンスのページにも、大きなデータでは shallowRef や shallowReactive で深いリアクティビティーを避けられること、その場合はネストしたオブジェクトを書き換えず、ルートの状態を置き換えて更新することが書かれています。
どのくらいの件数から検討するか
件数の少ないデータでは、ref の読み取りの負担は体感できる差になりません。表示が遅いと感じたときに、数千〜数万件のリストや、階層の深いオブジェクトを shallowRef に切り替えてみる、という順序が取り組みやすい進め方です。切り替えた後は、更新を「差し替え」で書く必要がある点だけ押さえておきます。
isReactiveで中身の状態を確認する
それぞれの API でできた値が、どこまでリアクティブになっているかを isReactive・isShallow・isProxy で確かめます。
import { ref, shallowRef, reactive, shallowReactive, markRaw, toRaw, isRef, isReactive, isShallow, isProxy } from 'vue'
const src = () => ({ nested: { n: 1 } })
const row = (label, v, nested) =>
console.log(label.padEnd(26), `isRef=${String(isRef(v)).padEnd(5)} isShallow=${String(isShallow(v)).padEnd(5)} | 中身 isReactive=${String(isReactive(nested(v))).padEnd(5)} | ネスト isReactive=${isReactive(nested(v).nested)}`)
row('ref(obj)', ref(src()), (v) => v.value)
row('shallowRef(obj)', shallowRef(src()), (v) => v.value)
row('reactive(obj)', reactive(src()), (v) => v)
row('shallowReactive(obj)', shallowReactive(src()), (v) => v)
row('ref(markRaw(obj))', ref(markRaw(src())), (v) => v.value)
row('reactive(markRaw(obj))', reactive(markRaw(src())), (v) => v)
console.log('--- toRaw ---')
const original = src()
const state = reactive(original)
console.log('state === original :', state === original)
console.log('toRaw(state) === original :', toRaw(state) === original)
console.log('isProxy(state) :', isProxy(state), '/ isProxy(toRaw(state)):', isProxy(toRaw(state)))
console.log('--- markRaw は入れ子の1階層だけに効く ---')
const store = reactive({ config: markRaw({ theme: { color: 'red' } }), rows: [] })
console.log('store.config :', isReactive(store.config))
console.log('store.config.theme :', isReactive(store.config.theme))
const leaked = reactive({ theme: store.config.theme }) // 中のオブジェクトを別の reactive に入れる
console.log('取り出して reactive に入れた theme:', isReactive(leaked.theme))
console.log('同じオブジェクトか :', leaked.theme === store.config.theme)
実行結果:
ref(obj) isRef=true isShallow=false | 中身 isReactive=true | ネスト isReactive=true
shallowRef(obj) isRef=true isShallow=true | 中身 isReactive=false | ネスト isReactive=false
reactive(obj) isRef=false isShallow=false | 中身 isReactive=true | ネスト isReactive=true
shallowReactive(obj) isRef=false isShallow=true | 中身 isReactive=true | ネスト isReactive=false
ref(markRaw(obj)) isRef=true isShallow=false | 中身 isReactive=false | ネスト isReactive=false
reactive(markRaw(obj)) isRef=false isShallow=false | 中身 isReactive=false | ネスト isReactive=false
--- toRaw ---
state === original : false
toRaw(state) === original : true
isProxy(state) : true / isProxy(toRaw(state)): false
--- markRaw は入れ子の1階層だけに効く ---
store.config : false
store.config.theme : false
取り出して reactive に入れた theme: true
同じオブジェクトか : false
| 作り方 | 中身が isReactive |
ネストが isReactive |
|---|---|---|
ref(obj) |
true | true |
shallowRef(obj) |
false | false |
reactive(obj) |
true | true |
shallowReactive(obj) |
true | false |
ref(markRaw(obj)) |
false | false |
reactive(markRaw(obj)) |
false | false |
shallowRefの.valueは、入れたオブジェクトそのものです。 プロキシに包まれていません。toRawで取り出したものは、元のオブジェクトと同じ(===が true)です。reactiveが返すプロキシは、元のオブジェクトとは別物として扱われます。markRawの印は、付けたオブジェクト自身にだけ効きます。 出力の最後のように、中のオブジェクト(theme)を取り出して別のreactiveに入れると、そちらはプロキシになり、元のthemeとは===で一致しなくなります。公式ドキュメントでは、これを同一性に関する注意点として説明しています。markRawしたオブジェクトの中身は、取り出して別のリアクティブな状態に入れず、そのオブジェクトを通して使うのが分かりやすい使い方です。
チャートや地図などライブラリのインスタンスを保持する
refに入れるとエラーになる例
チャートや地図、エディターなどのライブラリは、new で作ったインスタンスのメソッドを呼んで操作します。こうしたインスタンスを ref に入れると、プロキシ経由でメソッドを呼ぶことになり、プライベートフィールド(# で始まるフィールド)を持つクラスではエラーになります。ここでは、ライブラリのインスタンスを想定した確認用のクラス ChartLike を用意して確かめます。
import { ref, shallowRef, markRaw, reactive, isReactive } from 'vue'
// チャートや地図のライブラリを想定した確認用のクラス(プライベートフィールドを持つ)
class ChartLike {
#series = []
constructor(el) { this.el = el; this.options = { legend: { show: true } } }
setData(series) { this.#series = series; return this }
get size() { return this.#series.length }
destroy() { this.#series = [] }
}
console.log('=== ref に入れる ===')
const a = ref(new ChartLike('#chart'))
console.log('isReactive:', isReactive(a.value))
try {
a.value.setData([1, 2, 3])
} catch (e) {
console.log(`${e.name}: ${e.message}`)
}
console.log('=== shallowRef に入れる ===')
const b = shallowRef(null)
b.value = new ChartLike('#chart')
console.log('isReactive:', isReactive(b.value))
console.log('size:', b.value.setData([1, 2, 3]).size)
console.log('=== markRaw してから reactive の中に置く ===')
const state = reactive({ title: '月別の件数', chart: markRaw(new ChartLike('#chart')) })
console.log('isReactive:', isReactive(state.chart))
console.log('size:', state.chart.setData([1, 2]).size)
console.log('=== Map / Set / Date を ref に入れた場合 ===')
const m = ref(new Map([['a', 1]]))
console.log('Map isReactive:', isReactive(m.value), '/ get:', m.value.get('a'))
const d = ref(new Date(2026, 9, 3))
console.log('Date isReactive:', isReactive(d.value), '/ getFullYear:', d.value.getFullYear())
実行結果:
=== ref に入れる ===
isReactive: true
TypeError: Cannot write private member #series to an object whose class did not declare it
=== shallowRef に入れる ===
isReactive: false
size: 3
=== markRaw してから reactive の中に置く ===
isReactive: false
size: 2
=== Map / Set / Date を ref に入れた場合 ===
Map isReactive: true / get: 1
Date isReactive: false / getFullYear: 2026
refに入れるとインスタンスがプロキシになり、setDataの中で#seriesに書き込む時点でTypeErrorになります。 プライベートフィールドは、クラスが作った本来のオブジェクトからしか読み書きできないためです。shallowRefに入れた場合と、markRawを付けてreactiveの中に置いた場合は、インスタンスがそのまま保たれ、メソッドが動きます。Mapはrefに入れるとリアクティブになり、Dateはなりません。 Vue がリアクティブにするのは、普通のオブジェクト・配列・Map・Setなどに限られます。
プライベートフィールドを使っていないライブラリでも、インスタンスの内部まで監視の対象にする必要はないので、shallowRef か markRaw で持たせるのが軽くて確実です。
コンポーネントで保持するコード
描画先の要素をテンプレート参照で受け取り、onMounted でインスタンスを作って shallowRef に入れます。データが変わったらインスタンスのメソッドで描き直し、アンマウント前に破棄します。
import { JSDOM } from 'jsdom'
const dom = new JSDOM('<div id="app"></div>')
globalThis.window = dom.window
globalThis.document = dom.window.document
globalThis.Element = dom.window.Element
globalThis.SVGElement = dom.window.SVGElement
const { createApp, ref, shallowRef, watch, onMounted, onBeforeUnmount, h, nextTick, isReactive } = await import('vue')
// チャートや地図のライブラリを想定した確認用のクラス
class ChartLike {
#series = []
constructor(el) { this.el = el; console.log(`[chart] 作成: <${el.tagName.toLowerCase()}>`) }
setData(series) { this.#series = series; this.el.textContent = `棒 ${series.length} 本`; console.log('[chart] setData:', series.join(',')) }
destroy() { console.log('[chart] destroy') }
}
const SalesChart = {
props: { series: { type: Array, required: true } },
setup(props) {
const el = ref(null) // 描画先の要素(テンプレート参照)
const chart = shallowRef(null) // ライブラリのインスタンスは shallowRef で持つ
onMounted(() => {
chart.value = new ChartLike(el.value)
chart.value.setData(props.series)
console.log('isReactive(chart.value):', isReactive(chart.value))
})
// データが変わったら、インスタンスのメソッドで描き直す
watch(() => props.series, (series) => chart.value?.setData(series))
onBeforeUnmount(() => {
chart.value?.destroy()
chart.value = null
})
return () => h('div', { ref: el, class: 'chart' })
},
}
const series = shallowRef([120, 80, 150])
const app = createApp({ render: () => h(SalesChart, { series: series.value }) })
app.mount('#app')
console.log('画面:', document.querySelector('.chart').textContent)
series.value = [...series.value, 90]
await nextTick()
console.log('画面:', document.querySelector('.chart').textContent)
app.unmount()
実行結果:
[chart] 作成: <div>
[chart] setData: 120,80,150
isReactive(chart.value): false
画面: 棒 3 本
[chart] setData: 120,80,150,90
画面: 棒 4 本
[chart] destroy
実際のライブラリを使うときは、new ChartLike(el.value) の部分をそのライブラリの初期化に、setData と destroy をそのライブラリの更新・破棄のメソッドに置き換えます。構成のポイントは次の4つです。
| ポイント | 理由 |
|---|---|
インスタンスは shallowRef(null) で持つ |
プロキシに包まれず、メソッドがそのまま動く |
作成は onMounted の中 |
描画先の要素ができてから初期化する |
データの変更は watch でインスタンスに伝える |
ライブラリ側の更新メソッドを呼ぶ |
onBeforeUnmount で破棄して null を入れる |
ライブラリが確保した資源を解放する |
インスタンスをテンプレートで使わないなら、let chart = null と普通の変数で持つ方法もあります。shallowRef にしておくと、「インスタンスができたら表示を切り替える」といった処理を .value の変化で書けます。
shallowRef・shallowReactive・markRawの使い分け
判断表
| 状況 | 選ぶもの | 更新の書き方 |
|---|---|---|
| 通常のフォームや一覧の状態 | ref / reactive |
そのまま書き換える |
| 数千〜数万件以上のリスト、API の応答をそのまま表示する | shallowRef |
新しい配列を代入する |
| 大きな配列の一部だけを書き換えたい | shallowRef + triggerRef |
書き換えた後に triggerRef |
| ライブラリのインスタンス(単独で持つ) | shallowRef |
作成時と破棄時に代入する |
ライブラリのインスタンスを reactive やストアの中に置く |
markRaw |
インスタンスのメソッドで操作する |
| 動的コンポーネントに渡すコンポーネント定義 | shallowRef または markRaw |
切り替え時に代入する |
| 1階層目だけ監視できれば十分なオブジェクト | shallowReactive |
2階層目以降は差し替える |
| リアクティブなデータを一時的に素早く読みたい | toRaw |
読み取り専用で使う |
| リアクティブなデータを外部のライブラリに渡す | toRaw |
渡す時点の元のオブジェクトを渡す |
つまずきやすい点と直し方
| 起きること | 理由 | 直し方 |
|---|---|---|
shallowRef の中身を書き換えても画面が変わらない |
.value の差し替えだけを検知する |
新しいオブジェクトを代入する、または triggerRef |
list.value.push() が反映されない |
配列の中身の変更は検知されない | list.value = [...list.value, item] |
.value に同じオブジェクトを代入しても変わらない |
同じ値への代入は変更とみなされない | コピーを代入する、または triggerRef |
watch(shallow, fn, { deep: true }) が動かない |
中身がリアクティブではないため検知できない | 差し替えるか triggerRef を呼ぶ |
triggerRef を呼び忘れる |
書き換えと通知が別の行になる | 更新用の関数を1つ作り、その中で書き換えと triggerRef を行う |
インスタンスのメソッドで TypeError が出る |
ref や reactive がプロキシで包んでいる |
shallowRef か markRaw で持つ |
markRaw したのに一部がリアクティブになる |
中のオブジェクトを取り出して別の状態に入れた | 取り出さず、元のオブジェクトを通して使う |
shallowReactive の2階層目が反応しない |
1階層目だけが対象 | 2階層目はオブジェクトごと差し替える |
よくある質問
Q. refとshallowRefは、どちらを基本にするとよいですか?
A. 基本は ref です。中身をそのまま書き換えられるので、コードが素直になります。大きなデータで表示が重いときや、ライブラリのインスタンスを持たせるときに shallowRef を選びます。
Q. triggerRefは通常のrefにも使えますか?
A. 使えます。ただし通常の ref は中身の変更を自動で検知するので、呼ぶ場面はほとんどありません。triggerRef は shallowRef の中身を直接書き換えたときのための関数です。
Q. shallowRefとmarkRawはどう使い分けますか?
A. 値を単独で持つなら shallowRef、reactive のオブジェクトや Pinia のストアなど、深くリアクティブになる入れ物の中に置くなら markRaw です。markRaw は入れ物の種類に関係なく、そのオブジェクトをプロキシにしないようにします。
Q. shallowRefに入れた配列をv-forで表示できますか?
A. できます。表示は ref と同じように書けます。違いは更新のときで、要素の追加や変更は新しい配列を代入するか、書き換えの後に triggerRef を呼びます。テンプレート文字列に v-for を書いたコンポーネントでも、配列の差し替えで項目が増えることを確かめました。
まとめ
shallowRefは.valueの差し替えだけを検知し、中身はリアクティブにしません。isReactiveで確かめると、中身はfalseです。- 中身を更新するときは、新しい配列やオブジェクトを代入するか、書き換えた後に
triggerRefを呼びます。 同じオブジェクトの再代入では反映されません。 - 10万件の配列では、全件の読み取りが
refで約161ms、shallowRefで約3ms でした。watchEffectの中では差がさらに広がります。生成の時間はどちらもごくわずかです。 - ライブラリのインスタンスは
shallowRefで持ちます。refに入れるとプロキシに包まれ、プライベートフィールドを持つクラスではメソッドがエラーになります。 reactiveやストアの中に置くときはmarkRaw、1階層目だけ監視したいオブジェクトはshallowReactiveを使います。toRawは元のオブジェクトを取り出します。 大量の読み取りや、外部のライブラリへの受け渡しに向きます。- まず
refで書き、重さを感じた箇所からshallowRefに切り替えるのが進めやすい順序です。
参考
- Vue.js 公式 API リファレンス「リアクティビティー API: 上級編」 https://ja.vuejs.org/api/reactivity-advanced
- Vue.js 公式ガイド「パフォーマンス」 https://ja.vuejs.org/guide/best-practices/performance
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Vue shallowRef・triggerRefの使い方|refとの違い」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。