こんにちは、かつコーチです。
前回は、Vueでよく出るエラーとその解決法を初心者向けにまとめました。
今回はもう一段掘り下げて、上級者でも意外とハマる「リアクティビティが効かない」という現象を扱います。
エラーメッセージが出ないだけに原因調査が厄介なテーマですが、Vueのリアクティビティの仕組みを理解すれば、パターンごとに原因を切り分けられるようになります。
リアクティビティの仕組みをおさらいする
Proxyベースの仕組み
Vue 3のリアクティビティは、refやreactiveで作った値をJavaScriptのProxyでラップすることで実現しています。
Proxyは「値の読み書きを横取りして、変更を検知する」ための仕組みです。
reactive(obj)を実行すると、元のobjとは別のProxyオブジェクトが返され、Vueはそのプロパティへの読み書きを監視します。
この「元のオブジェクトとProxyは別物」という点が、リアクティビティが効かなくなる多くのケースの根本原因になっています。
ref と reactive で検知の仕組みが少し違う
refは内部の.valueへの読み書きをProxy経由で検知し、reactiveはオブジェクトの各プロパティへの読み書きをProxy経由で検知します。
どちらも「Vueが用意したProxy経由でアクセスしているかどうか」が、リアクティビティが効くかどうかの分かれ目になる点は共通しています。
パターン1:reactiveの分割代入でリアクティビティが失われる
原因:Proxyから値を取り出すと参照が切れる
reactiveで作ったオブジェクトを分割代入すると、取り出した値は単なるプリミティブ値になり、リアクティビティが失われます。
❌ Before:reactiveオブジェクトを分割代入する
<script setup lang="ts">
import { reactive } from 'vue'
interface State {
count: number
message: string
}
const state = reactive<State>({ count: 0, message: 'hello' })
// countはこの時点で「ただの数値」になり、stateとの結びつきが切れる
const { count } = state
const increment = (): void => {
state.count++
console.log(count) // stateを更新してもcountは0のまま変化しない
}
</script>
const { count } = stateとした瞬間、countはstate.countという参照ではなく、その時点の値をコピーしただけの独立した変数になります。
その後state.countを更新しても、すでに切り離されたcountには反映されません。
私自身、複数のプロパティをまとめて分割代入して1つのcomputedに渡すコードを書いていたときにこれを踏み、「なぜ画面が更新されないのか」半日ほど悩んだ経験があります。
✅ After:toRefsで分割代入してもリアクティビティを保つ
<script setup lang="ts">
import { reactive, toRefs } from 'vue'
interface State {
count: number
message: string
}
const state = reactive<State>({ count: 0, message: 'hello' })
// toRefsで分割代入しても、それぞれがstateと結びついたrefのまま
const { count, message } = toRefs(state)
const increment = (): void => {
state.count++
console.log(count.value) // stateの更新がcount.valueにも反映される
}
</script>
toRefsは、reactiveオブジェクトの各プロパティを、元のオブジェクトと結びついたrefに変換してくれる関数です。
コンポーネントから複数の値をreturn風に公開したいときや、propsを分割して使いたいときは、toRefs(単一のプロパティだけならtoRef)を使うのが定石です。
パターン2:配列やオブジェクトの直接代入でリアクティビティが効かない
原因:インデックス直接代入や存在しないプロパティの追加
Vue 2時代は、配列のインデックス直接代入や、reactiveオブジェクトへの新規プロパティ追加が検知されないという制限がありました。
Vue 3はProxyベースになったことでこの制限の多くは解消されていますが、それでも「参照ごと入れ替える」操作でハマるケースは残っています。
❌ Before:配列を空にしたつもりが参照が切り替わっていない
<script setup lang="ts">
import { reactive } from 'vue'
const todos = reactive<string[]>(['牛乳を買う', '掃除をする'])
const clearTodos = (): void => {
// 新しい配列を代入しているようで、実はreactiveの外側に新規変数を作っているだけ
const newTodos: string[] = []
todos.value = newTodos // reactiveなのでvalueは存在せず、これはただのバグ
}
</script>
これはrefとreactiveの使い分けを混同したときによく起きるミスです。
reactiveで作った変数には.valueが存在しないため、todos.value = newTodosは意図した代入にならず、静かに失敗します(TypeScriptならコンパイルエラーで気づけます)。
✅ After:reactiveの中身は配列メソッドやlengthで操作する
<script setup lang="ts">
import { reactive } from 'vue'
const todos = reactive<string[]>(['牛乳を買う', '掃除をする'])
const clearTodos = (): void => {
// 配列のlengthを0にすることで、Proxy経由の変更として検知される
todos.length = 0
}
const addTodo = (title: string): void => {
todos.push(title)
}
</script>
reactiveで管理している配列は、push ・ splice ・ lengthの変更など、配列自体のメソッド・プロパティ経由で操作することでリアクティビティが正しく効きます。
配列そのものを丸ごと入れ替えたい場合は、refで管理する方がシンプルです。
<script setup lang="ts">
import { ref } from 'vue'
const todos = ref<string[]>(['牛乳を買う', '掃除をする'])
const clearTodos = (): void => {
todos.value = [] // refなら参照の丸ごと入れ替えが自然に書ける
}
</script>
「配列やオブジェクトを部分的に更新するならreactive、丸ごと入れ替えることがあるならref」と使い分けると、この手のハマりを未然に防げます。
パターン3:propsを直接書き換えてしまい警告が出る
原因:propsは一方向のデータフロー
これはVue全般で頻出するミスですが、上級者でも実装を急いでいるときについやってしまいがちです。
<script setup lang="ts">
const props = defineProps<{ initialCount: number }>()
// propsを直接書き換えようとすると警告が出て、実際には反映されない
const increment = (): void => {
props.initialCount++
}
</script>
Vueのpropsは「親から子への一方向のデータフロー」を前提に設計されているため、子コンポーネント内で直接書き換えることは想定されていません。
コンソールに「Attempting to mutate prop “initialCount”」という警告が出て、値も実際には更新されません。
propsの値を元に子コンポーネント内で独自に変化させたい状態がある場合は、refやreactiveにコピーしてローカルステートとして持ちましょう。
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{ initialCount: number }>()
// propsの値を初期値としてローカルなrefにコピーする
const count = ref<number>(props.initialCount)
const increment = (): void => {
count.value++
}
</script>
パターン4:関数の外にreactiveな値を切り出して壊れる
コンポーザブル関数の戻り値がリアクティビティを保っているか
Composition APIでロジックを切り出す「コンポーザブル関数」を自作する場合も、戻り値の作り方次第でリアクティビティが失われることがあります。
❌ Before:reactiveオブジェクトのプロパティをそのまま返す
// composables/useCounter.ts
import { reactive } from 'vue'
export function useCounter() {
const state = reactive({ count: 0 })
const increment = (): void => {
state.count++
}
// stateをそのままではなく、プロパティだけを取り出して返してしまっている
return { count: state.count, increment }
}
呼び出し側で受け取ったcountは、呼び出した瞬間の値がコピーされただけのプリミティブ値になり、その後incrementを呼んでも画面には反映されません。
✅ After:toRefsで返すか、refをそのまま返す
// composables/useCounter.ts
import { reactive, toRefs } from 'vue'
export function useCounter() {
const state = reactive({ count: 0 })
const increment = (): void => {
state.count++
}
// toRefsでラップしてから展開すれば、リアクティビティを保ったまま返せる
return { ...toRefs(state), increment }
}
コンポーザブル関数からリアクティブな値を返すときは、「呼び出し側で分割代入されても壊れない形」で返す、というのを常に意識しておくと安全です。
デバッグの手順
かつコーチが実践している切り分け方
リアクティビティが効かないと感じたとき、私は次の順番で切り分けています。
- 対象の値が
refかreactiveか、テンプレート・スクリプトの両方で正しくアクセスしているか確認する - その値が分割代入や関数の戻り値として「コピー」されていないか確認する
- Vue Devtoolsで実際にstateの値が更新されているかを確認する(更新されているのに画面が変わらない場合は描画側の問題)
- 更新されていない場合は、Proxyの外で値を操作していないか(配列の直接代入や、
reactiveへの丸ごと再代入など)を疑う
「値自体は更新されているのに画面が変わらない」のか、「そもそも値自体が更新されていない」のかを最初に切り分けることが、原因特定の一番の近道です。
まとめ
この記事のポイント
- Vue 3のリアクティビティはProxyベースで、Proxyの外に値を取り出すと結びつきが切れる
reactiveを分割代入するとリアクティビティが失われるため、toRefsを使うreactiveな配列やオブジェクトは、メソッドやプロパティ経由で部分更新する(丸ごと入れ替えるならrefを使う)- propsは直接書き換えず、ローカルステートにコピーしてから操作する
- コンポーザブル関数の戻り値は、分割代入されても壊れない形(
toRefsなど)で返す
次に読むべき記事
次回は、Vueアプリ全体のパフォーマンスチューニングについて、上級者向けに掘り下げます。
→ 次の記事:Vueアプリのパフォーマンスチューニング入門