- Vue.js
Vue refとreactiveの違いと使い分け|toRefs早見表
refとreactiveの違いとは、「値を .value という箱に入れて持つか(ref)、オブジェクトそのものをリアクティブな代理(Proxy)に包むか(reactive)」の違いです。 refは数値や文字列を含むどんな値にも使えて丸ごと入れ替えができ、reactiveはオブジェクト・配列専用で .value なしに書けるかわりに、分割代入や再代入をすると画面との結び付きが切れます。
この記事では、画面が更新されなくなる8パターンを Vue 3.5.43 で実際に動かし、変更前後の表示をそのまま掲載しています。それぞれに toRefs・toRef・storeToRefs などの直し方を対で置き、shallowRef と Vue 3.5 の props 分割代入まで扱います。迷ったときに早見表だけ見れば判断できる構成にしました。
refとreactiveの早見表
まずは4つの API を同じ軸で比べます。型の欄は TypeScript 7.0.2 で実際に表示された型名です。
| 項目 | ref | reactive | shallowRef | toRefs |
|---|---|---|---|---|
| 入れられる値 | 何でも(数値・文字列・オブジェクト・配列) | オブジェクト・配列・Map・Set のみ | 何でも | reactive のオブジェクトを渡す |
| script 内での読み書き | .value が必要 | .value 不要(state.count) | .value が必要 | 各プロパティに .value が必要 |
| テンプレートでの書き方 | 自動アンラップ(count と書ける) | state.count | 自動アンラップ | 分割代入後は count と書ける |
| 丸ごと再代入 | できる(count.value = 新しい値) | できない(結び付きが切れる) | できる(これが唯一の更新手段) | 対象外(元のオブジェクトに連動) |
| 分割代入 | .value から取り出すと切れる | 切れる | 切れる | 切れない(このための関数) |
| 深い階層の変更検知 | する | する | しない(.value の入れ替えのみ) | 元の reactive に従う |
| 型(TypeScript) | Ref<number, number> |
元の型そのまま | ShallowRef<T, T> |
ToRefs<T> |
| 向いている場面 | 迷ったらこれ。単一の値、入れ替える配列やオブジェクト | フォームのように項目がまとまった状態 | 行数の多い一覧、外部ライブラリのインスタンス | reactive を分割代入したいとき |
使い分けの結論は次の3行です。
| 状況 | 選ぶもの |
|---|---|
| どちらにするか迷った、値を丸ごと入れ替える可能性がある | ref |
| 関連する項目をひとまとめにして .value なしで書きたい(分割代入・再代入はしない) | reactive |
| reactive や props、ストアから一部を取り出して使いたい | toRefs / toRef / storeToRefs |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Vue refとreactiveの違いと使い分け|toRefs早見表」
検証した環境と確認の方法
この記事の実行結果は、次の環境で得たものです。
| 項目 | バージョン |
|---|---|
| Vue | 3.5.43 |
| Pinia | 4.0.3 |
| @vue/test-utils | 2.5.1 |
| jsdom | 30.1.1 |
| TypeScript | 7.0.2 |
| Node.js | 22.22.2 |
単一ファイルコンポーネント(SFC)を @vue/compiler-sfc でコンパイルし、Vue Test Utils で jsdom 上にマウントして、ボタンを2回クリックする前と後の表示テキストを読み取っています。ブラウザの画面そのものではなく、jsdom 上の DOM のテキストである点はご了承ください。結果の行は、ボタンの文字を除いた表示部分を「前」「後」の2行に整えて載せています(値は出力のままです)。
refとreactiveの基本の書き方
refは .value という箱に値を入れる
<script setup>
import { ref } from 'vue'
const count = ref(0) // 数値
const user = ref({ name: 'Sato', age: 20 }) // オブジェクトも入る
function inc() {
count.value++ // script では .value
user.value.age++ // 中のプロパティも検知される
user.value = { name: 'Kato', age: 30 } // 丸ごと入れ替えてもよい
}
</script>
<template>
<button @click="inc">+1</button>
<p>{{ count }} / {{ user.name }} {{ user.age }}</p> <!-- .value は不要 -->
</template>
ref にオブジェクトを入れると、中身は内部で reactive に変換されます。実際に確かめると、次のとおりでした。
isReactive(ref({}).value): true
つまり ref はオブジェクトに対しても reactive と同じ深さで変更を検知します。「オブジェクトだから reactive にする」という決まりはありません。
reactiveはオブジェクトを代理で包む
<script setup>
import { reactive } from 'vue'
const form = reactive({ name: '', email: '', agree: false })
function reset() {
form.name = '' // .value は不要
form.email = ''
form.agree = false
}
</script>
<template>
<input v-model="form.name" />
<input v-model="form.email" />
<button @click="reset">クリア</button>
</template>
reactive が返すのは元のオブジェクトではなく、その代理(Proxy)です。数値や文字列を渡すと、警告が出てそのままの値が返ります。
reactive(raw) === raw: false | reactive(raw) === reactive(raw): true
[Vue warn] value cannot be made reactive: 1
reactive(1): 1
代理を通した読み書きだけが追跡される、というのが reactive の仕組みです。このあと紹介する「リアクティブが失われる」現象は、ほとんどがこの代理を経由しなくなることで起こります。
リアクティブが失われる8パターンと直し方の一覧
先に全体像を表にします。番号は以降の見出しに対応しています。
| No. | 書き方 | 起こること | 直し方 |
|---|---|---|---|
| 1 | reactive を分割代入する | 取り出した変数を変えても画面はそのまま | toRefs で分割代入する |
| 2 | reactive の変数に再代入する | 画面は最初のオブジェクトを見続ける | ref にする/Object.assign で中身を書き換える |
| 3 | 関数に state.count を値で渡す | 関数の中の computed が最初の値のまま | toRef かゲッター関数を渡す |
| 4 | count.value を別の変数に入れる | ただの数値のコピーになる | ref のまま持ち回る |
| 5 | ref のオブジェクトを分割代入する | 1 と同じ | toRefs(user.value) |
| 6 | reactive の配列に filter の結果を再代入する | 画面の一覧が減らない | ref の配列にして .value を入れ替える |
| 7 | shallowRef の中身だけを書き換える | 画面が変わらない | .value を入れ替える/triggerRef |
| 8 | props や Pinia のストアを分割代入する | 親やストアの変更が届かない | Vue 3.5 の props 分割代入/storeToRefs |
パターン1:reactiveを分割代入すると画面が更新されない
再現コード
<script setup>
import { reactive } from 'vue'
const state = reactive({ count: 0 })
let { count } = state // ここで数値の 0 がコピーされる
const inc = () => { count++ }
</script>
<template>
<button @click="inc">+1</button>
<p>count={{ count }} state.count={{ state.count }}</p>
</template>
実行結果(ボタンを2回クリック):
前: count=0 state.count=0
後: count=0 state.count=0
分割代入した時点で、count は代理から切り離されたただの数値になります。count++ で変数の中身は 2 になっていますが、Vue はその変更を知る手段がないため、再描画そのものが起こりません。state.count も 0 のままです。
直し方:toRefsで分割代入する
<script setup>
import { reactive, toRefs } from 'vue'
const state = reactive({ count: 0 })
const { count } = toRefs(state) // count は state.count につながった ref
const inc = () => { count.value++ }
</script>
<template>
<button @click="inc">+1</button>
<p>count={{ count }} state.count={{ state.count }}</p>
</template>
実行結果:
前: count=0 state.count=0
後: count=2 state.count=2
toRefs は、reactive の各プロパティを「元のオブジェクトにつながった ref」に変換します。count.value を変えると state.count も変わり、その逆も成り立ちます。
toRefs 双方向 state2.a: 9
パターン2:reactiveの変数に再代入すると画面が更新されない
再現コード
<script setup>
import { reactive } from 'vue'
let state = reactive({ count: 0 })
const inc = () => {
state = reactive({ count: state.count + 1 }) // 新しい代理に差し替え
}
</script>
<template>
<button @click="inc">+1</button>
<p>state.count={{ state.count }}</p>
</template>
実行結果:
前: state.count=0
後: state.count=0
テンプレートが依存として記録しているのは「最初の代理の count プロパティ」です。変数 state を別の代理に向け直しても、最初の代理には何も変更が加わっていないので再描画が起こりません。API から取得したデータを state = 取得結果 と代入する書き方で、よく出会う現象です。
直し方A:refにして .value を入れ替える
<script setup>
import { ref } from 'vue'
const state = ref({ count: 0 })
const inc = () => {
state.value = { count: state.value.count + 1 }
}
</script>
<template>
<button @click="inc">+1</button>
<p>state.count={{ state.count }}</p>
</template>
前: state.count=0
後: state.count=2
直し方B:Object.assignで中身を書き換える
<script setup>
import { reactive } from 'vue'
const state = reactive({ count: 0 })
const inc = () => {
Object.assign(state, { count: state.count + 1 })
}
</script>
<template>
<button @click="inc">+1</button>
<p>state.count={{ state.count }}</p>
</template>
前: state.count=0
後: state.count=2
| 直し方 | 向いている場面 |
|---|---|
| ref にする | 取得したデータで丸ごと入れ替えることが多い状態(一覧、詳細データ) |
| Object.assign | すでに reactive で書かれたフォームに、取得した値を流し込みたいとき |
「丸ごと入れ替える可能性がある状態は ref」と決めておくと、この現象には出会わなくなります。reactive は const で宣言しておくと、再代入しようとした時点でエディタや実行時に気付けます。
パターン3:関数に値を渡すとcomputedが更新されない
コンポーザブル(use で始まる再利用関数)に状態を渡すときに起こります。
再現コード
<script setup>
import { reactive, computed } from 'vue'
function useDouble(n) {
return computed(() => n * 2) // n はただの数値
}
const state = reactive({ count: 0 })
const double = useDouble(state.count) // 0 という値が渡る
const inc = () => { state.count++ }
</script>
<template>
<button @click="inc">+1</button>
<p>count={{ state.count }} double={{ double }}</p>
</template>
実行結果:
前: count=0 double=0
後: count=2 double=0
state.count と書いた時点で数値の 0 が取り出され、関数にはその 0 だけが渡ります。count は 2 に増えているのに、double は 0 のままです。
直し方:toRefかゲッター関数を渡し、受け取る側はtoValueで読む
<script setup>
import { reactive, computed, toRef, toValue } from 'vue'
function useDouble(n) {
// ref・ゲッター関数・ただの値のどれが来ても読める
return computed(() => toValue(n) * 2)
}
const state = reactive({ count: 0 })
const double = useDouble(toRef(state, 'count')) // ref として渡す
const double2 = useDouble(() => state.count) // ゲッター関数として渡す
const inc = () => { state.count++ }
</script>
<template>
<button @click="inc">+1</button>
<p>count={{ state.count }} double={{ double }} double2={{ double2 }}</p>
</template>
実行結果:
前: count=0 double=0 double2=0
後: count=2 double=4 double2=4
toValue は「ref なら .value、関数なら呼び出した結果、それ以外はそのまま」を返す関数です。実際の戻り値は次のとおりでした。
toValue: 1 2 3 | unref: 2
(順に toValue(1)、toValue(ref(2))、toValue(() => 3)、unref(ref(2)) の結果です。)
コンポーザブルの引数を toValue で読むようにしておくと、呼び出す側は ref でもゲッター関数でも渡せるようになります。
パターン4:.valueを別の変数に入れると切れる
再現コード
<script setup>
import { ref } from 'vue'
const count = ref(0)
let n = count.value // 数値 0 のコピー
const inc = () => { n++ }
</script>
<template>
<button @click="inc">+1</button>
<p>n={{ n }} count={{ count }}</p>
</template>
実行結果:
前: n=0 count=0
後: n=0 count=0
ref の強みは「箱ごと持ち回れる」ことです。.value を取り出すのは、読む・書くその瞬間だけにして、変数や引数には箱(ref)のまま渡すようにします。直し方は count.value++ と書くだけなので、コードは省略します。
パターン5:refに入れたオブジェクトを分割代入すると切れる
再現コード
<script setup>
import { ref } from 'vue'
const user = ref({ name: 'Sato', age: 20 })
let { age } = user.value
const inc = () => { age++ }
</script>
<template>
<button @click="inc">+1</button>
<p>age={{ age }} user.age={{ user.age }}</p>
</template>
前: age=20 user.age=20
後: age=20 user.age=20
直し方:toRefs(user.value)
user.value は reactive な代理なので、toRefs にそのまま渡せます。
<script setup>
import { ref, toRefs } from 'vue'
const user = ref({ name: 'Sato', age: 20 })
const { age } = toRefs(user.value)
const inc = () => { age.value++ }
</script>
<template>
<button @click="inc">+1</button>
<p>age={{ age }} user.age={{ user.age }}</p>
</template>
前: age=20 user.age=20
後: age=22 user.age=22
ここで1点、知っておくと役立つことがあります。toRefs で作った ref は「その時点の user.value」につながっています。あとで user.value を丸ごと入れ替えると、age は入れ替え前のオブジェクトを指したままになります。丸ごと入れ替える状態から一部を取り出すときは、computed(() => user.value.age) のように毎回たどる書き方が合っています。
パターン6:reactiveの配列にfilterの結果を再代入すると減らない
パターン2の配列版で、一覧から1件削除する処理でよく見かけます。
再現コード
<script setup>
import { reactive } from 'vue'
let items = reactive([1, 2, 3])
const remove = () => {
items = items.filter(n => n !== 2) // 新しい配列に差し替え
}
</script>
<template>
<button @click="remove">del</button>
<p>{{ items.join(',') }}</p>
</template>
前: 1,2,3
後: 1,2,3
直し方:refの配列にする
<script setup>
import { ref } from 'vue'
const items = ref([1, 2, 3])
const remove = () => {
items.value = items.value.filter(n => n !== 2)
}
</script>
<template>
<button @click="remove">del</button>
<p>{{ items.join(',') }}</p>
</template>
前: 1,2,3
後: 1,3
filter・map・sort の結果で入れ替える書き方は配列操作でとても多いので、配列は ref で持つのが扱いやすい選択です。reactive の配列のまま続けたい場合は、splice のように元の配列を書き換えるメソッドを使います。
パターン7:shallowRefは中身の書き換えでは更新されない
shallowRef は「.value の入れ替えだけを検知する」軽量な ref です。中身を reactive に変換しないので、行数の多い一覧や、地図・グラフなど外部ライブラリのインスタンスを持つときに向いています。
再現コード
<script setup>
import { shallowRef } from 'vue'
const s = shallowRef({ count: 0 })
const inc = () => { s.value.count++ } // 中身だけを変更
</script>
<template>
<button @click="inc">+1</button>
<p>{{ s.count }}</p>
</template>
前: 0
後: 0
直し方A:.valueを新しいオブジェクトに入れ替える
<script setup>
import { shallowRef } from 'vue'
const s = shallowRef({ count: 0 })
const inc = () => { s.value = { count: s.value.count + 1 } }
</script>
<template>
<button @click="inc">+1</button>
<p>{{ s.count }}</p>
</template>
前: 0
後: 2
直し方B:triggerRefで更新を知らせる
<script setup>
import { shallowRef, triggerRef } from 'vue'
const s = shallowRef({ count: 0 })
const inc = () => {
s.value.count++
triggerRef(s) // 「変わりました」と手動で通知
}
</script>
<template>
<button @click="inc">+1</button>
<p>{{ s.count }}</p>
</template>
前: 0
後: 2
同じ考え方の shallowReactive もあり、こちらは1階層目のプロパティだけを追跡します。
shallowReactive nested isReactive: false
| API | 検知する範囲 |
|---|---|
| ref / reactive | すべての階層 |
| shallowRef | .value の入れ替えのみ |
| shallowReactive | 1階層目のプロパティの代入のみ |
パターン8:propsとPiniaのストアを分割代入する
propsの分割代入はVue 3.5からリアクティブ
Vue の公式ガイド(Props のページ、今回参照)によると、3.4 以前は分割代入した props は定数のままでしたが、3.5 以降はコンパイラが自動で props. を補うようになりました。次のコードをコンパイルして確かめます。
<script setup>
import { computed } from 'vue'
const { count, label = '件' } = defineProps(['count', 'label'])
const double = computed(() => count * 2)
</script>
<template>
<span>{{ count }}{{ label }} double={{ double }}</span>
</template>
@vue/compiler-sfc 3.5.43 が出力したコード(抜粋):
export default {
__name: 'Child',
props: /*@__PURE__*/_mergeDefaults(['count', 'label'], {
label: '件'
}),
setup(__props) {
const double = computed(() => __props.count * 2)
count と書いた箇所が __props.count に置き換わり、既定値の「件」は props の定義に移っています。親から渡す値を 1 から 5 に変えた結果は次のとおりです。
[3.5 の分割代入] 前: 1件 double=2 / 後: 5件 double=10
[props を変数に入れてから分割代入] 前: 1件 double=2 / 後: 1件 double=2
2行目は、次のように defineProps の戻り値をいったん変数に入れてから分割代入した場合です。この形はコンパイラの変換対象にならず、パターン1と同じく値のコピーになります。
const props = defineProps(['count', 'label'])
const { count, label = '件' } = props // こちらは変換されない
分割代入したpropsをwatchに渡すときはゲッターで
分割代入した props をそのまま watch に渡すと、コンパイルの時点で次のメッセージが出ます。
[@vue/compiler-sfc] "count" is a destructured prop and should not be passed directly to watch(). Pass a getter () => count instead.
メッセージのとおり、ゲッター関数にすると変更を受け取れます。
<script setup>
import { watch } from 'vue'
const { count } = defineProps(['count'])
watch(() => count, (v) => console.log('getter:' + v))
</script>
watch(() => count) log: [ 'getter:2' ]
自作のコンポーザブルに渡すときも同じで、useDouble(() => count) のようにゲッターで渡します。
| props の扱い方 | リアクティブか | 補足 |
|---|---|---|
| props.count と書く | はい | どのバージョンでも同じ |
| defineProps の戻り値を直接分割代入(3.5 以降) | はい | コンパイラが props.count に変換 |
| 変数に入れた props を分割代入 | いいえ | toRefs(props) を使う |
| toRef(props, 'count') | はい | ref として関数に渡したいとき |
PiniaのストアはstoreToRefsで分割代入する
import { ref, computed } from 'vue'
import { createPinia, defineStore, setActivePinia, storeToRefs } from 'pinia'
setActivePinia(createPinia())
const useCounter = defineStore('counter', () => {
const count = ref(0)
const double = computed(() => count.value * 2)
function inc() { count.value++ }
return { count, double, inc }
})
const store = useCounter()
let { count: plain, inc } = store // そのまま分割代入
const { count: cRef, double: dRef } = storeToRefs(store) // storeToRefs
inc(); inc()
console.log(plain, cRef.value, dRef.value, store.count)
実行結果(Pinia 4.0.3):
[pinia] 分割代入 plain = 0 / storeToRefs count = 2 double = 4 / store.count = 2
ストアは reactive なオブジェクトなので、そのまま分割代入した plain は 0 のままです。状態とゲッターは storeToRefs で、アクション(関数)はストアから直接分割代入する、と分けて書きます。
const store = useCounter()
const { count, double } = storeToRefs(store) // 状態・ゲッター
const { inc } = store // アクション
toRefs・toRef・storeToRefs・toValueの使い分け
| 関数 | 渡すもの | 返るもの | 使う場面 |
|---|---|---|---|
| toRefs(obj) | reactive のオブジェクト、props | 各プロパティが ref になったオブジェクト | 分割代入したい、コンポーザブルから返したい |
| toRef(obj, 'key') | reactive のオブジェクトとキー | そのプロパティにつながった ref | 1つだけ取り出して関数に渡す |
toRef(() => 式) |
ゲッター関数 | 読み取り専用の ref | 計算した値を ref の形で渡したい |
| storeToRefs(store) | Pinia のストア | 状態とゲッターの ref | ストアの分割代入 |
| toValue(x) | ref・ゲッター・値 | 中身の値 | コンポーザブルの引数を読む |
| unref(x) | ref・値 | 中身の値 | ref かもしれない値を読む |
toRefs と toRef には、存在しないキーの扱いに違いがあります。
toRefsで存在しないキー: undefined
toRefなら: true undefined
toRefs は呼んだ時点で存在するプロパティだけを ref にします。あとから追加されるかもしれない任意のプロパティは toRef(obj, 'key') で取り出すと、ref として受け取れます(2行目は「ref であること」と「現在の値が undefined であること」を表しています)。
コンポーザブルから返すときの型
reactive のままコンポーザブルから返すと、呼び出し側が分割代入したときにパターン1が起こります。toRefs で包んで返すと、呼び出し側は安心して分割代入できます。
import { reactive, toRefs } from 'vue'
export function useMouse() {
const state = reactive({ x: 0, y: 0 })
// ...イベントで state.x / state.y を更新
return toRefs(state) // { x: Ref, y: Ref }
}
// 呼び出し側
const { x, y } = useMouse() // 分割代入しても連動する
テンプレートでの自動アンラップが効く範囲
テンプレートで .value を省略できるのは、トップレベルの ref だけです。次のコードで確かめます。
<script setup>
import { ref } from 'vue'
const count = ref(1)
const obj = { id: ref(1) } // ただのオブジェクトの中に ref
const { id } = obj // トップレベルに取り出す
const inc = () => { count.value++; obj.id.value++ }
</script>
<template>
<button @click="inc">+1</button>
<p>A={{ count + 1 }} B={{ obj.id + 1 }} C={{ obj.id }} D={{ id + 1 }}</p>
</template>
実行結果:
前: A=2 B=[object Object]1 C=1 D=2
後: A=4 B=[object Object]1 C=3 D=4
| 式 | 結果 | 理由 |
|---|---|---|
| A: count + 1 | 計算できる | トップレベルの ref はアンラップされる |
| B: obj.id + 1 | [object Object]1 | ネストした ref は式の中ではアンラップされない |
| C: obj.id | 表示できる | 二重波かっこの最終的な値が ref のときは中身が表示される |
| D: id + 1 | 計算できる | 分割代入でトップレベルに出したため |
reactive の中に入れた ref は、種類によって扱いが変わります。
reactive内のref自動アンラップ re.n: 1 | 配列内: true | Map内: true
(re.n は数値として読めています。「配列内」「Map内」は isRef の結果で、true は ref のまま=アンラップされていないことを表します。)
| 置き場所 | 自動アンラップ |
|---|---|
| reactive のオブジェクトのプロパティ | される(state.n で読める) |
| reactive の配列の要素 | されない(list[0].value が必要) |
| reactive の Map の値 | されない(map.get('k').value が必要) |
TypeScriptでの型と .value の付け忘れの見つけ方
TypeScript を使うと、.value の付け忘れはコンパイル時に見つかります。次のファイルを tsc --noEmit(TypeScript 7.0.2、strict 有効)にかけました。
import { ref, reactive, toRefs, computed } from 'vue'
const count = ref(0)
const state = reactive({ count: 0, name: 'Sato' })
const refs = toRefs(state)
const double = computed(() => count.value * 2)
const a = count + 1 // .value を付け忘れた
count.value = 'ten' // 型が合わない
double.value = 10 // computed は読み取り専用
const b: number = refs.count // toRefs の戻りは Ref
実際のエラー文:
t1.ts(10,11): error TS2365: Operator '+' cannot be applied to types 'Ref<number, number>' and 'number'.
t1.ts(11,1): error TS2322: Type 'string' is not assignable to type 'number'.
t1.ts(12,8): error TS2540: Cannot assign to 'value' because it is a read-only property.
t1.ts(13,7): error TS2322: Type 'Ref<number, number>' is not assignable to type 'number'.
それぞれの変数がどんな型として推論されるかも、エラー文から確かめました。
| 宣言 | 推論された型 |
|---|---|
| ref(0) | Ref<number, number> |
| reactive({ count: 0, name: 'Sato' }) | { count: number; name: string; } |
| shallowRef({ rows: [1, 2, 3] }) | ShallowRef<{ rows: number[]; }, { rows: number[]; }> |
| toRefs(state) | ToRefs<{ count: number; name: string; }> |
computed(() => count.value * 2) |
ComputedRef<number> |
reactive の型は元のオブジェクトの型と同じに見えるため、型だけではリアクティブかどうかを区別できません。ref は Ref という型が付くので、関数の引数や戻り値を見ただけで「これは箱だ」とわかります。チームで読み書きするコードでは、この見分けやすさが ref を選ぶ理由のひとつになります。
型を明示したいときの書き方は次のとおりです。
import { ref, reactive } from 'vue'
import type { Ref } from 'vue'
interface Member { id: number; name: string }
const selected = ref<Member | null>(null) // 初期値が null のとき
const members = ref<Member[]>([]) // 空配列から始めるとき
const form: Member = reactive({ id: 0, name: '' }) // reactive は変数側に型を書く
function useMember(id: Ref<number>) { /* ... */ }
refとreactiveの使い分けの判断表
| 持ちたい状態 | おすすめ | 理由 |
|---|---|---|
| 数値・文字列・真偽値 | ref | reactive には入れられない |
| API から取得して丸ごと入れ替える一覧・詳細 | ref | .value の入れ替えで済む(パターン2・6) |
| filter・map・sort の結果で更新する配列 | ref | 再代入が自然に書ける |
| 入力フォームの項目のまとまり | reactive または ref | 再代入しないなら .value なしで書ける reactive が読みやすい |
| コンポーザブルの戻り値 | ref(または toRefs で包んだもの) | 呼び出し側が分割代入できる |
| 数千行の一覧、外部ライブラリのインスタンス | shallowRef | 中身を変換しないので軽い |
| props から一部を関数に渡す | toRef / ゲッター関数 | 値渡しを避ける(パターン3) |
| Pinia の状態を取り出す | storeToRefs | パターン8 |
チームで方針をそろえるなら「基本は ref、フォームのようなまとまりだけ reactive」がわかりやすい線引きです。どちらを選んでも、検知の深さや性能に大きな差はありません。差が出るのは、分割代入・再代入・関数への受け渡しをしたときの振る舞いです。
更新されないときのチェックリスト
| 確認すること | 見つけ方 |
|---|---|
| reactive や props を分割代入していないか | const { ... } = の右辺を見る |
| reactive の変数が let で宣言され、再代入されていないか | const にしてエラーになる箇所を探す |
| 関数の引数に state.xxx や xxx.value を渡していないか | ref・ゲッター関数で渡す形に直す |
| shallowRef の中身だけを書き換えていないか | .value の入れ替えにする |
| テンプレートでネストした ref を式に使っていないか | トップレベルに取り出す |
| ストアを storeToRefs なしで分割代入していないか | 状態は storeToRefs、アクションは直接 |
よくある質問
Q. refとreactiveはどちらを使えばよいですか? A. 迷ったら ref を選ぶと、分割代入や再代入で結び付きが切れる場面が少なくなります。ref はどんな値も入り、丸ごと入れ替えができ、型にも Ref と表れるためです。フォームのように項目がまとまっていて再代入しない状態は、reactive にすると .value なしで書けます。
Q. .valueはいつ必要ですか? A. script の中で ref を読み書きするときに必要です。テンプレートではトップレベルの ref に限り自動で外れます。reactive のプロパティに入れた ref も自動で外れますが、配列の要素や Map の値に入れた ref は .value が必要でした(Vue 3.5.43 で確認)。
Q. toRefsとtoRefの違いは何ですか? A. toRefs はオブジェクトの全プロパティをまとめて ref にし、分割代入に使います。toRef はプロパティを1つだけ ref にします。toRefs は呼んだ時点で存在するプロパティだけが対象なので、存在しないかもしれないキーは toRef で取り出します。
Q. Vue 3.5ではpropsを分割代入してもよいのですか? A. defineProps の戻り値を直接分割代入する形であれば、コンパイラが props.count の形に変換するためリアクティブです。いったん変数に入れた props を分割代入した場合は変換されず、今回の検証でも値が更新されませんでした。watch やコンポーザブルに渡すときはゲッター関数で包みます。
まとめ
- ref は .value の箱、reactive はオブジェクトの代理。ref はどんな値にも使えて丸ごと入れ替えられる
- reactive は分割代入・再代入・値渡しで結び付きが切れる。8パターンとも実行結果で確認できた
- 分割代入したいときは toRefs、1つだけなら toRef、Pinia は storeToRefs
- 関数にはref かゲッター関数で渡し、受け取る側は toValue で読む
- shallowRef は .value の入れ替えだけを検知する。中身を変えたら triggerRef
- Vue 3.5 では defineProps の直接の分割代入がリアクティブ。watch にはゲッターで渡す
- 迷ったら ref、まとまりを .value なしで書きたいときだけ reactive
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Vue refとreactiveの違いと使い分け|toRefs早見表」
株式会社RJCでは、Vueのリアクティビティの仕組みを仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。