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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?