Vue 3 の Composition API で reactive を分割代入してリアクティビティが失われる
reactive() で作ったオブジェクトを分割代入や引数渡しすると Proxy 経由のアクセスが切れてリアクティブでなくなる。
toRefs() で個別の Ref に展開するか、プリミティブは ref() を使う。
公開: 更新:
要約
reactive() が返すのは Vue が生成した Proxy オブジェクトで、リアクティビティは Proxy 経由のプロパティアクセスに依存している。const { count } = state のように分割代入すると Proxy を経由しない素の値が取り出され、以降の変更が UI に反映されない。toRefs() で個別の Ref に変換するか、最初から ref() を使うのが正解。
実行例
分割代入したほうは watchEffect が最初の 1 回しか動かず、state.count が 1 になっても取り出した変数は 0 のままだが、toRefs を通すと同じ更新で watchEffect が 2 回目も走り、値も 1 に追随した。
プリミティブを reactive に渡した実行では value cannot be made reactive の警告が出て、戻り値が素の number のまま返っている。
$ node bad.mjs
watchEffect が見ている count: 0
state.count = 1
分割代入した count = 0$ node solve_torefs.mjs
watchEffect が見ている count: 0
watchEffect が見ている count: 1
state.count = 1
toRefs した count = 1$ node solve_ref.mjs
[Vue warn] value cannot be made reactive: 0
reactive(0) の戻り値 = 0 / typeof = number— 2026-09-08 時点の出力
検証環境
- 検証日
- 実行環境
node:20Debian GNU/Linux 12 (bookworm)- バージョン
- Node.js 20.20.2
- npm 10.8.2
- Python 3.11.2
- Git 2.39.5
この記事の「実行例」は、上記の環境で実際にコマンドを実行して得られた出力をそのまま掲載しています。 再現手順はリポジトリの検証スクリプトとして管理し、定期的に再実行して出力を更新しています。
よくある原因
reactive({ count: 0 })の戻り値をconst { count } = stateで分割代入し、テンプレートにcountを直接渡しているreactive(0)のようにプリミティブを直接渡している(公式 API リファレンス(新しいタブで開く) はreactiveがオブジェクトのみを受け付けると明記)- コンポーザブルが返した reactive オブジェクトを受け取り側で
const value = state.countのようにスナップショットしている watch(() => state.x, ...)の代わりにwatch(state.x, ...)を書いており、依存追跡が効いていない
解決策
1. toRefs で分割代入する
import { reactive, toRefs } from 'vue'
const state = reactive({ count: 0, name: 'foo' })
const { count, name } = toRefs(state)
// count.value で読み書き。テンプレートでは {{ count }} で自動アンラップtoRefs は各プロパティを Ref に変換するため、Proxy を経由したアクセスが維持される。
2. プリミティブには ref を使う
import { ref } from 'vue'
const count = ref(0)
function inc() {
count.value++
}ref はオブジェクトでラップして .value 経由でアクセスさせる仕組み。
プリミティブはこちらを基本とする。
3. watch / computed の依存はゲッタで渡す
import { reactive, watch, computed } from 'vue'
const state = reactive({ count: 0 })
watch(() => state.count, (n) => console.log(n))
const doubled = computed(() => state.count * 2)第一引数を関数(ゲッタ)にすると、Vue が依存追跡を行える。watch(state.count, ...) は値そのものを渡しており追跡できない。
4. 受け渡しの境界では Ref に変換する
import { reactive, toRef } from 'vue'
const state = reactive({ user: { name: 'foo' } })
useLogger(toRef(state, 'user'))
// useLogger 側は Ref<User> として受け取れるコンポーザブルや関数の境界でリアクティビティを保ちたいときは toRef / toRefs で個別の Ref に変換してから渡す。