メインコンテンツまでスキップ

リアクティビティ — ref と reactive の使い分け

count.value += 1 と書くと、画面の数字が変わります。値を書き換えただけで、描画を指示する命令はどこにも書いていません。Vue が値の読み書きを見張っていて、読まれた場所を覚え、書き換わったときにそこを描き直しているからです。

この見張りには 2 つの入口があります。ref()reactive() です。どちらを選ぶかで書き方が変わり、選び方を間違えると「エラーが出て止まる」のではなく「静かに追跡が切れて画面が更新されない」という形で失敗します。エラーが出ないので原因を探しにくく、Vue を書き始めた人が最初につまずく場所になっています。

この章で学ぶこと

  • 値の種類から refreactive のどちらを使うか判断できる
  • .value が必要な場所と不要な場所を、その理由から説明できる
  • 再代入と分割代入で追跡が切れる仕組みを説明できる
  • toRefs が何を保つのか理解して、必要な場面で使える

ref — 値を包んで読み書きを捕まえる

ref() は渡した値を value プロパティを持つオブジェクトに包みます。

components/Counter.vue
<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)

const increment = () => {
count.value += 1
}
</script>

<template>
<p>カウント: {{ count }}</p>
<button @click="increment">+1</button>
</template>

なぜ包む必要があるのでしょうか。JavaScript には、変数への代入を横から検知する手段がありません。let count = 0 と書いて count = 1 としたとき、その代入を第三者が知る方法はないのです。

一方でオブジェクトのプロパティなら、アクセサを定義すれば読み書きに割り込めます。そこで Vue は値をオブジェクトの中に移し、value の読み取りと書き込みを自分の管理下に置きます。読み取り時に「いま描画中の処理がこの値を使った」と記録し、書き込み時に記録された処理へ再実行を通知します。

内部実装はアクセサを持つクラスです。value の getter で依存を記録し、setter で変更を通知します。ref そのものは Proxy ではありません。 ただしオブジェクトを渡した場合は、.value に入る中身が reactive() を通されるため、そこは Proxy になります。この二重構造は後の toRefs の話で効いてきます。

.value が要る場所と要らない場所

スクリプトの中では .value が必要ですが、テンプレートでは不要です。

components/Counter.vue(テンプレートでは .value を書かない)
<script setup lang="ts">
import { ref } from 'vue'

const message = ref('hello')

// スクリプト内では .value が必要
console.log(message.value)
</script>

<template>
<!-- テンプレートでは自動で開かれる -->
<p>{{ message }}</p>
</template>

この非対称は不親切に見えますが、理由があります。テンプレートが参照する値は、Vue が用意したプロキシを経由して読まれます。そのプロキシは読み取り先が ref なら中身を返すので、.value を書かなくても値が得られます。スクリプトは普通の JavaScript として実行されるだけなので、同じ介入ができません。Vue が読み取りに割り込める側だけが省略できるという切り分けです。

自動で開かれるのは、script setup のトップレベルで束縛した ref です。reactive() のプロパティに入れた ref も、プロパティを読んだ時点でアンラップされます。

素のオブジェクトに入れて渡した ref は、扱いが揺れます。{{ box.count }} のように値をそのまま表示する場合は開かれますが、{{ box.count + 1 }} のように式の中で使ったり属性へ渡したりすると ref オブジェクトのまま扱われます。テンプレートへ渡す値を素のオブジェクトで包まないのが安全です。

型引数を書くべきとき

初期値から決まる型がそのまま欲しいなら、型引数は書かなくてよいものです。

import { ref } from 'vue'

// 推論で Ref<number> になる。型引数は不要
const count = ref(0)

// 推論では Ref<null> になってしまうため、型引数で広げる
const selectedId = ref<number | null>(null)

// 推論では Ref<string> に広がってしまうため、型引数で狭める
const theme = ref<'light' | 'dark'>('light')

ref(0)ref<number>(0) と書いても意味は変わりません。型引数が働くのは推論の結果を変えたいときで、方向は 2 つあります。null から始めて後で値が入る変数は型を広げる必要があり、決まった選択肢だけを入れたい変数は型を狭める必要があります。どちらでもないなら、型引数は推論と同じ結果を書き直すだけになります。本ガイドは推論に任せる書き方を既定にしますが、これは公式のスタイルガイドが定めた規則ではなく本ガイドの方針です。

reactive — オブジェクトごと Proxy で包む

reactive() はオブジェクトを Proxy で包みます。プロパティへのアクセスが Proxy を経由するので、.value は要りません。

components/UserCard.vue
<script setup lang="ts">
import { reactive } from 'vue'

const user = reactive({
name: '田中',
age: 30,
})

const celebrateBirthday = () => {
// .value は不要。ネストしたプロパティも追跡される
user.age += 1
}
</script>

<template>
<p>{{ user.name }}さん({{ user.age }}歳)</p>
<button @click="celebrateBirthday">誕生日</button>
</template>

書き味は素直ですが、公式ドキュメントは制約を 3 つ挙げています1

制約内容
対象が限られるオブジェクト、配列、MapSet のみ。数値や文字列には使えません
全体を置き換えられない変数に別のオブジェクトを代入すると追跡が切れます
取り出すと切れるプロパティを分割代入したり関数へ渡すと、そこから先は追跡されません

Composition API で状態を宣言する方法として、公式が推奨しているのは ref() です2。上の制約が理由で、reactive は「使ってはいけない」のではなく「制約を知って選ぶもの」という位置づけになります。

追跡が切れる 2 つのパターン

制約の 2 番目と 3 番目は、実際に書いていて踏みやすい形で現れます。

変数への再代入

import { reactive } from 'vue'

let state = reactive({ count: 0 })

// 素の変数への代入は追跡されないため、この時点では再描画が起きない
state = reactive({ count: 10 })

理由は「テンプレートが古い Proxy を握り続けるから」ではありません。テンプレートは変数を読むたびに、その時点の値を見ます。反映されないのは、素の変数への代入が Vue の追跡対象ではなく、再描画が起こらないからです。

この違いは挙動に出ます。同じコンポーネントで別のリアクティブな値を変更して再描画を発生させると、そのとき初めて 10 が表示されます。「変更したのに反映されない」だけでなく「無関係な操作をしたら突然反映される」という形で現れるので、原因を掴みにくい種類の不具合になります。

まとめて入れ替えたいなら ref を使います。ref は入れ物が固定されていて、中身だけを差し替えられるからです。

import { ref } from 'vue'

const state = ref({ count: 0 })

// 入れ物は同じまま中身が変わるので、こちらは反映される
state.value = { count: 10 }

分割代入

import { reactive } from 'vue'

const state = reactive({ count: 0 })

// count には数値のコピーが入る
let { count } = state

// state.count は 0 のまま
count += 1

分割代入は右辺のプロパティを読んで、その値を新しい変数に入れる操作です。読んだ時点の数値がコピーされるだけなので、変数と元のプロパティの間に結びつきは残りません。関数へ state.count を渡した場合も同じで、渡るのは数値です。

これは Vue の制限ではなく JavaScript の値の受け渡しの仕組みそのものです。Proxy は読み書きに割り込めますが、読み終わって取り出された数値まで追いかけることはできません。

toRefs — 分割代入しても結びつきを保つ

分割代入したいときは toRefs() を通します。プロパティごとに ref を作り、元のプロパティと双方向に同期させます。

import { reactive, toRefs } from 'vue'

const state = reactive({ count: 0, label: 'total' })

const { count, label } = toRefs(state)

// ref 側の変更が元へ伝わる
count.value += 1
console.log(state.count) // 1

// 元の変更が ref 側へ伝わる
state.count += 1
console.log(count.value) // 2

コピーではなく、そのプロパティを指す ref が作られます。だから両方向に伝わります。

toRefs は reactive なオブジェクトを対象にした関数です。 ref を直接渡すと、開発ビルドで「reactive object を期待したが plain なオブジェクトを受け取った」という警告が出ます。そもそも ref は value という 1 つのプロパティしか持たないので、分割代入して複数の変数に配りたいという場面自体が起きません。

使いどころは Composable の戻り値です。内部を reactive で書きつつ、呼び出し側に分割代入させたいときに変換します。

composables/useCounter.ts
import { reactive, toRefs } from 'vue'

export const useCounter = (initial = 0) => {
const state = reactive({ count: initial, step: 1 })

const increment = () => {
state.count += state.step
}

// 分割代入しても壊れない形で返す
return { ...toRefs(state), increment }
}

変換されるのは、呼び出した時点で列挙できるプロパティだけです。あとから足すプロパティには ref が作られないので、その場合は toRef() を使います。

どちらを使うか

判断は値の形と、入れ替えるかどうかで決まります。

状況選ぶもの理由
数値、文字列、真偽値refreactive はプリミティブを扱えません
null から始めて後で入れるref (型引数を明示)入れ物が固定されていて中身を差し替えられます
配列やオブジェクトを丸ごと入れ替えるref再代入で追跡が切れません
関連する値をひとまとめに扱うどちらでもreactive はプロパティ名で書けて読みやすくなります
Composable から返すref または toRefs を通した reactive呼び出し側の分割代入に耐えます

迷ったら ref に寄せるのが安全です。.value を書く手間は増えますが、追跡が切れる条件を覚えておく負担よりは軽くなります。

まとめ

  • ref は値を value プロパティに包み、その読み書きに割り込むことで変更を検知します。ref 自体は Proxy ではなく、アクセサを持つオブジェクトです
  • .value はスクリプトでのみ必要です。テンプレート側は Vue のプロキシを経由して読むため省略できます。reactive のプロパティに入れた ref もアンラップされます
  • 型引数を書くのは、推論の結果を広げたいか狭めたいときです。初期値どおりの型でよければ不要です
  • reactive は Proxy でオブジェクトを包みます。プリミティブには使えず、変数への再代入と分割代入で追跡が切れます
  • 再代入が反映されないのは参照先が古いからではなく、素の変数への代入が追跡されず再描画が起きないためです
  • 追跡が切れるのは JavaScript が値をコピーして渡すためです。Proxy は取り出された値まで追えません
  • toRefs は reactive なオブジェクトのプロパティを ref に変換し、双方向の結びつきを保ちます。ref に対しては使いません
関連リファレンス

次に読む

Footnotes

  1. 出典: Reactivity Fundamentals — Limitations of reactive()。「オブジェクト型のみ」「オブジェクト全体を置き換えられない」「分割代入すると壊れる」の 3 点が挙げられています。