toRefsとは、reactiveオブジェクトの各プロパティを、元のオブジェクトとつながったrefに変換する関数です。toRefは、そのうち1つのプロパティだけをrefにします。 reactive をそのまま分割代入すると、取り出した変数はただの値になり、元のオブジェクトが変わっても更新されません。toRefs を通してから分割代入すると、つながりを保ったまま取り出せます。

この記事では、分割代入で値が更新されなくなる様子を実行ログで再現したうえで、toRefs・toRef・getter 形式の toRef・toValue の使い分けを表にまとめます。Vue 3.5 の props 分割代入、コンポーザブルの戻り値を toRefs で返す型付きコード、Pinia の storeToRefs との違い、存在しないプロパティを扱うときの注意も、すべて出力つきで確かめました。

確認環境:Ubuntu 24.04 / Node.js 22.22.2 / Vue 3.5.43 / Pinia 4.0.3 / TypeScript 7.0.2 / jsdom 30.1.1。コードは .mjs ファイルとして Node.js で実行しています。.vue ファイルの例は @vue/compiler-sfc 3.5.43 で JavaScript に変換し、jsdom 上にマウントして動かしました。

toRef・toRefsの早見表

やりたいこと 書き方 得られるもの
reactive を分割代入したい const { a, b } = toRefs(state) 各プロパティの ref(読み書きとも元とつながる)
プロパティを1つだけ ref にしたい const a = toRef(state, 'a') 1つの ref(読み書きとも元とつながる)
まだ無いかもしれないプロパティを ref にしたい toRef(state, 'memo') 後から追加されても追従する ref
無いときの既定値も決めたい toRef(state, 'size', 20) 値が undefined のあいだは既定値を返す ref
ネストした値や計算結果を ref として渡したい toRef(() => state.user.name)(3.3以上) 読み取り専用の ref
値・ref・getter のどれでも受け取りたい toValue(source)(3.3以上) 中身の値
props を分割代入したい const { count } = defineProps(...)(3.5以上) リアクティブなまま使える変数
Pinia のストアを分割代入したい const { items } = storeToRefs(store) state と getter の ref(アクションは含まない)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Vue toRef・toRefsの使い方|reactive分割代入の注意」

使い分けの比較表

関数 引数 戻り値 書き込み 主な用途
toRefs(obj) reactive オブジェクト 各プロパティが ref の普通のオブジェクト できる 分割代入、コンポーザブルの戻り値
toRef(obj, key) reactive オブジェクトとキー ref できる 1つだけ取り出す、無いかもしれないプロパティ
toRef(getter) 関数 読み取り専用の ref できない ネストした値や props をコンポーザブルに渡す
toRef(値) ref 以外の値 ref(値) と同じ できる 引数を ref にそろえる
toValue(source) 値・ref・getter 中身の値 ― コンポーザブルの引数を読む
storeToRefs(store) Pinia のストア state と getter の ref state はできる ストアの分割代入

reactiveを分割代入すると更新されなくなる

再現コード

コードは次の準備で動かせます。

mkdir torefs-demo && cd torefs-demo
npm init -y
npm install vue pinia jsdom
node demo.mjs   # コードを demo.mjs に保存して実行

同じ count を、そのまま分割代入した変数と、toRefs を通した変数の両方で取り出して比べます。

import { reactive, toRefs, watchEffect, nextTick } from 'vue'

const state = reactive({ count: 0, name: 'sato' })

// (1) そのまま分割代入する
const { count } = state
// (2) toRefs を通して分割代入する
const { count: countRef, name } = toRefs(state)

watchEffect(() => console.log(`[分割代入] count = ${count}`))
watchEffect(() => console.log(`[toRefs]   count = ${countRef.value}`))

console.log('--- state.count++ ---')
state.count++
await nextTick()
console.log(`count の中身: ${count} / countRef.value: ${countRef.value} / state.count: ${state.count}`)

console.log('--- countRef.value = 10 (ref 側から書く) ---')
countRef.value = 10
await nextTick()
console.log(`state.count: ${state.count}`)

console.log('--- name.value = "suzuki" ---')
name.value = 'suzuki'
console.log(`state.name: ${state.name}`)

実行結果:

[分割代入] count = 0
[toRefs]   count = 0
--- state.count++ ---
[toRefs]   count = 1
count の中身: 0 / countRef.value: 1 / state.count: 1
--- countRef.value = 10 (ref 側から書く) ---
[toRefs]   count = 10
state.count: 10
--- name.value = "suzuki" ---
state.name: suzuki

state.count++ のあと、[toRefs] の行だけが再実行されています。 そのまま分割代入した count は 0 のままです。

なぜ更新されないのか

reactive は、プロパティを「読んだ」「書いた」という操作をオブジェクト経由で検知しています。const { count } = state は const count = state.count と同じ意味で、その時点の数値 0 を新しい変数にコピーしているだけです。コピーした後の変数は元のオブジェクトと関係が無くなります。

toRefs が返す ref は、.value を読むと state.count を読み、.value に書くと state.count に書きます。値のコピーではなく、元のプロパティへの窓口になっているため、出力の後半のように ref 側から書いた値も state に反映されます。

書き方 変数の中身 元の変更が伝わる 変数から元を変更できる
const { count } = state 数値のコピー 伝わらない できない
const { count } = toRefs(state) 元とつながった ref 伝わる できる(count.value = 10)
const count = toRef(state, 'count') 元とつながった ref 伝わる できる
const { user } = state(オブジェクトのプロパティ) reactive オブジェクト 中身の変更は伝わる。state.user の差し替えは伝わらない 中身は変更できる

4行目のように、プロパティがオブジェクトの場合は取り出した変数もリアクティブです。ただし state.user = { ... } と丸ごと差し替えると、変数は差し替え前のオブジェクトを指したままになります。差し替えにも追従させたいときは toRefs か、後述の getter 形式の toRef を使います。

toRefの3つの形とtoValue

toRef には、引数の違いで3つの使い方があります。Vue 3.3 で加わった getter 形式と toValue もあわせて動かします。

import { reactive, ref, toRef, toValue, isRef, isReadonly, watchEffect, nextTick } from 'vue'

const state = reactive({ count: 0, user: { name: 'sato' } })

console.log('=== (1) toRef(オブジェクト, キー) ===')
const count = toRef(state, 'count')
count.value++
console.log('state.count:', state.count)
state.count = 5
console.log('count.value:', count.value)

console.log('=== (2) toRef(getter) : 3.3+ ===')
const userName = toRef(() => state.user.name)
console.log('isRef:', isRef(userName), '/ isReadonly:', isReadonly(userName))
watchEffect(() => console.log(`userName = ${userName.value}`))
state.user = { name: 'suzuki' }   // user を丸ごと差し替えても追従する
await nextTick()
try {
  userName.value = 'tanaka'        // 読み取り専用なので代入できない
} catch (e) {
  console.log(`${e.name}: ${e.message}`)
}

console.log('=== (3) toRef(値) / toRef(ref) ===')
const r = ref(1)
console.log('toRef(ref) は同じ ref を返す:', toRef(r) === r)
console.log('toRef(1) は ref(1) と同じ:', toRef(1).value)

console.log('=== (4) toValue : 3.3+ ===')
console.log('toValue(1)        =', toValue(1))
console.log('toValue(ref(2))   =', toValue(ref(2)))
console.log('toValue(() => 3)  =', toValue(() => 3))
console.log('toValue(count)    =', toValue(count))

実行結果:

=== (1) toRef(オブジェクト, キー) ===
state.count: 1
count.value: 5
=== (2) toRef(getter) : 3.3+ ===
isRef: true / isReadonly: true
userName = sato
userName = suzuki
TypeError: Cannot set property value of #<GetterRefImpl> which has only a getter
=== (3) toRef(値) / toRef(ref) ===
toRef(ref) は同じ ref を返す: true
toRef(1) は ref(1) と同じ: 1
=== (4) toValue : 3.3+ ===
toValue(1)        = 1
toValue(ref(2))   = 2
toValue(() => 3)  = 3
toValue(count)    = 5
  • (1) toRef(オブジェクト, キー):読み書きの両方が元のプロパティとつながります。
  • (2) toRef(getter):関数の結果を読み取り専用の ref にします。state.user を丸ごと差し替えても、関数が毎回 state.user.name を読み直すので追従しています。代入すると TypeError になります(ES モジュールは strict モードで動くため、エラーとして表れます)。
  • (3) toRef(値):すでに ref ならそのまま返し、ref でなければ ref(値) と同じものを作ります。
  • (4) toValue:値・ref・getter のどれを渡しても、中身の値を返します。unref と違い、関数を渡すと呼び出した結果を返します。

getter形式のtoRefとcomputedの違い

どちらも読み取り専用の ref になりますが、性質が異なります。

比べる点 toRef(() => x.y) computed(() => x.y)
結果のキャッシュ しない(読むたびに関数を呼ぶ) する
向く処理 プロパティを読むだけの軽い処理 計算に時間がかかる処理
主な用途 props やネストした値をコンポーザブルに渡す 値から別の値を作る

プロパティを読むだけなら toRef(() => ...)、絞り込みや合計などの計算をするなら computed を選びます。

存在しないプロパティにtoRefsは使えない

toRefs は、呼び出した時点でオブジェクトに存在するプロパティだけを ref にします。後から追加される可能性があるプロパティには toRef を使います。

import { reactive, ref, toRef, toRefs, watchEffect, nextTick } from 'vue'

const form = reactive({ title: '下書き' })

console.log('=== 存在しないプロパティを toRefs で取り出す ===')
const { title, memo } = toRefs(form)
console.log('title:', title.value, '/ memo:', memo)
console.log('toRefs の戻り値のキー:', Object.keys(toRefs(form)))

console.log('=== 同じことを toRef で ===')
const memoRef = toRef(form, 'memo')
console.log('memoRef.value:', memoRef.value)
watchEffect(() => console.log(`memo = ${memoRef.value}`))
form.memo = 'あとで追加'
await nextTick()
console.log('追加後の toRefs のキー:', Object.keys(toRefs(form)))

console.log('=== 既定値つきの toRef(オブジェクト, キー, 既定値) ===')
const size = toRef(form, 'size', 20)
console.log('size.value:', size.value, '/ form.size:', form.size)

console.log('=== リアクティブでないオブジェクトを toRefs に渡す ===')
const plain = { a: 1 }
const { a } = toRefs(plain)
a.value = 2
console.log('plain.a:', plain.a)

console.log('=== ref に入れたオブジェクトは .value に対して toRefs ===')
const user = ref({ name: 'sato', age: 30 })
const { name } = toRefs(user.value)
user.value.name = 'suzuki'
console.log('name.value:', name.value)
user.value = { name: 'tanaka', age: 41 }   // 丸ごと差し替え
console.log('差し替え後の name.value:', name.value)
const nameGetter = toRef(() => user.value.name)
console.log('getter 形式の toRef:', nameGetter.value)

実行結果:

=== 存在しないプロパティを toRefs で取り出す ===
title: 下書き / memo: undefined
toRefs の戻り値のキー: [ 'title' ]
=== 同じことを toRef で ===
memoRef.value: undefined
memo = undefined
memo = あとで追加
追加後の toRefs のキー: [ 'title', 'memo' ]
=== 既定値つきの toRef(オブジェクト, キー, 既定値) ===
size.value: 20 / form.size: undefined
=== リアクティブでないオブジェクトを toRefs に渡す ===
[Vue warn] toRefs() expects a reactive object but received a plain one.
plain.a: 2
=== ref に入れたオブジェクトは .value に対して toRefs ===
name.value: suzuki
差し替え後の name.value: suzuki
getter 形式の toRef: tanaka

この出力からわかることを整理します。

場面 結果 対応
無いプロパティを toRefs で分割代入 変数が undefined になる(memo.value と書くと TypeError) toRef(form, 'memo') を使う
無いプロパティを toRef で取り出す .value は undefined。後から追加されると追従する そのまま使える
toRef(form, 'size', 20) ref からは 20 が読める。元のオブジェクトには書き込まれない 既定値を表示に使いたいときに
リアクティブでないオブジェクトを toRefs に渡す 警告が出る。読み書きはできるが、変更は検知されない 先に reactive() で包む
ref に入れたオブジェクトを分割代入 toRefs(user.value) で取り出せる .value を付けて渡す
その ref の .value を丸ごと差し替え 取り出した ref は差し替え前のオブジェクトを指したまま getter 形式の toRef(() => user.value.name) を使う

toRefs の戻り値は「その時点のプロパティの一覧」から作られる、と覚えておくと判断しやすくなります。省略可能なプロパティを持つ型(memo?: string など)を扱うときは、最初から toRef を選ぶと安心です。

Vue 3.5のprops分割代入

分割代入してもリアクティブなまま使える

Vue 3.5 からは、script setup で defineProps の戻り値を分割代入しても、リアクティブなまま使えます。コンパイラーが、分割代入した変数を props.count のような参照に書き換えるためです。

<script setup>
import { watch, watchEffect } from 'vue'

const { count, label = '件数' } = defineProps({
  count: Number,
  label: String,
})

watchEffect(() => console.log(`[watchEffect] ${label} = ${count}`))
watch(() => count, (v, old) => console.log(`[watch getter] ${old} -> ${v}`))
</script>

<template>
  <p id="out">{{ label }}: {{ count }}</p>
</template>

この .vue ファイルを @vue/compiler-sfc で変換し、変換結果の一部と実行結果を出力します。あわせて、うまくいかない書き方と、toRefs(props) を使う書き方も確かめます。

import fs from 'node:fs'
import { JSDOM } from 'jsdom'
import { parse, compileScript } from '@vue/compiler-sfc'

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, h, nextTick } = await import('vue')

// .vue を @vue/compiler-sfc で JavaScript に変換する
function compile(file) {
  const { descriptor } = parse(fs.readFileSync(file, 'utf8'), { filename: file })
  return compileScript(descriptor, { id: 'x', inlineTemplate: true }).content
}

console.log('=== Child.vue の変換結果(抜粋) ===')
const code = compile('b/sfc/Child.vue')
console.log(code.split('\n').filter((l) => /__props|watch/.test(l) && !/^import/.test(l)).join('\n'))

fs.writeFileSync('b/sfc/Child.out.mjs', code)
const Child = (await import('./sfc/Child.out.mjs')).default

console.log('=== 実行 ===')
const count = ref(1)
createApp({ render: () => h(Child, { count: count.value }) }).mount('#app')
console.log('画面:', document.querySelector('#out').textContent)
count.value = 2
await nextTick()
console.log('画面:', document.querySelector('#out').textContent)

console.log('=== ChildNg.vue(分割代入した props を watch に直接渡す) ===')
try {
  compile('b/sfc/ChildNg.vue')
} catch (e) {
  console.log(e.message)
}

console.log('=== ChildOld.vue(toRefs(props)) ===')
fs.writeFileSync('b/sfc/ChildOld.out.mjs', compile('b/sfc/ChildOld.vue'))
const ChildOld = (await import('./sfc/ChildOld.out.mjs')).default
const n = ref(1)
const host = document.createElement('div')
createApp({ render: () => h(ChildOld, { count: n.value }) }).mount(host)
n.value = 5
await nextTick()

エラーになる書き方(ChildNg.vue):

<script setup>
import { watch } from 'vue'

const { count } = defineProps({ count: Number })

watch(count, (v) => console.log(v))
</script>

<template>
  <p>{{ count }}</p>
</template>

toRefs(props) を使う書き方(ChildOld.vue):

<script setup>
import { toRefs, watch } from 'vue'

const props = defineProps({ count: Number })
const { count } = toRefs(props)

watch(count, (v, old) => console.log(`[toRefs(props)] ${old} -> ${v}`))
</script>

<template>
  <p>{{ count }}</p>
</template>

実行結果:

=== Child.vue の変換結果(抜粋) ===
  setup(__props) {
watchEffect(() => console.log(`[watchEffect] ${__props.label} = ${__props.count}`))
watch(() => __props.count, (v, old) => console.log(`[watch getter] ${old} -> ${v}`))
  return (_openBlock(), _createElementBlock("p", _hoisted_1, _toDisplayString(__props.label) + ": " + _toDisplayString(__props.count), 1 /* TEXT */))
=== 実行 ===
[watchEffect] 件数 = 1
画面: 件数: 1
[watchEffect] 件数 = 2
[watch getter] 1 -> 2
画面: 件数: 2
=== ChildNg.vue(分割代入した props を watch に直接渡す) ===
[@vue/compiler-sfc] "count" is a destructured prop and should not be passed directly to watch(). Pass a getter () => count instead.

b/sfc/ChildNg.vue
4  |  const { count } = defineProps({ count: Number })
5  |  
6  |  watch(count, (v) => console.log(v))
   |        ^^^^^
7  |  </script>
8  |  
=== ChildOld.vue(toRefs(props)) ===
[toRefs(props)] 1 -> 5
  • 変換結果では、count が __props.count に置き換わっています。 変数に見えても、実際には毎回 props から読んでいるので、親が値を変えると watchEffect もテンプレートも追従します。
  • 既定値は label = '件数' のように、JavaScript の分割代入の書き方で指定できます。
  • 分割代入した props を watch(count, ...) と直接渡すと、コンパイル時にエラーになります。 watch(() => count, ...) と getter で包みます。
  • toRefs(props) で ref にしてから渡す書き方も、引き続き使えます。 Vue 3.4 以前でも動かすコードでは、この書き方か props.count を使います。

propsをコンポーザブルに渡すときの書き方

分割代入した props をコンポーザブルに渡すときも、値そのものではなく getter で渡します。受け取る側は toValue で読めば、ref・getter・ただの値のどれが来ても同じコードで扱えます。

渡し方 書き方 親の変更に追従
値をそのまま渡す useFetch(id) しない(呼び出し時点の値が渡る)
getter で渡す useFetch(() => id) する
toRef で渡す useFetch(toRef(() => props.id)) する
toRefs(props) の ref を渡す useFetch(toRefs(props).id) する

コンポーザブルの戻り値をtoRefsで返す

型付きのコード

コンポーザブルの中では状態を reactive でまとめておき、返すときに toRefs を通すと、呼び出し側は分割代入で必要なものだけ受け取れます。TypeScript で書いた例です。

import { reactive, toRefs, toValue, computed, watchEffect } from 'vue'
import type { MaybeRefOrGetter, ToRefs } from 'vue'

interface PagerState {
  page: number
  pageSize: number
  loading: boolean
}

export function usePager(total: MaybeRefOrGetter<number>) {
  const state = reactive<PagerState>({ page: 1, pageSize: 20, loading: false })

  // 引数は 数値 / ref / getter のどれでも受け取れるよう toValue で読む
  const lastPage = computed(() => Math.max(1, Math.ceil(toValue(total) / state.pageSize)))

  function next() {
    if (state.page < lastPage.value) state.page++
  }

  // toRefs で返すと、呼び出し側で分割代入してもリアクティブなまま
  return { ...toRefs(state), lastPage, next }
}

// 戻り値の型を明示したい場合は ToRefs を使う
export function usePagerState(): ToRefs<PagerState> {
  return toRefs(reactive<PagerState>({ page: 1, pageSize: 20, loading: false }))
}

呼び出す側(main.ts):

import { ref } from 'vue'
import { usePager } from './usePager'

const total = ref(95)
const { page, pageSize, lastPage, next } = usePager(total)

next()
console.log(`page=${page.value} / lastPage=${lastPage.value}`)
pageSize.value = 50
console.log(`page=${page.value} / lastPage=${lastPage.value}`)

TypeScript 7.0.2 でコンパイルして実行した結果:

page=2 / lastPage=5
page=2 / lastPage=2

分割代入した pageSize に .value で書き込むと、コンポーザブルの中の state.pageSize が変わり、それを使う lastPage も再計算されています。

型エラーで書き間違いに気付ける

toRefs の戻り値は、各プロパティの型を保った ref になります。型に合わない代入や .value の付け忘れは、コンパイル時に見つかります。

import { usePager } from './usePager'

const { page, loading, lastPage } = usePager(() => 95)

page.value = '2'          // number の ref に文字列
loading.value = 1         // boolean の ref に数値
lastPage.value = 3        // computed は読み取り専用
const n: number = page    // .value の付け忘れ

npx tsc --noEmit -p . の実行結果:

ng.ts(5,1): error TS2322: Type 'string' is not assignable to type 'number'.
ng.ts(6,1): error TS2322: Type 'number' is not assignable to type 'boolean'.
ng.ts(7,10): error TS2540: Cannot assign to 'value' because it is a read-only property.
ng.ts(8,7): error TS2322: Type 'Ref<number, number>' is not assignable to type 'number'.

このときの tsconfig.json です。

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true
  },
  "include": ["*.ts"]
}

reactiveのまま返す場合との比較

返し方 呼び出し側の書き方 分割代入 テンプレートでの書き方
return state(reactive のまま) const pager = usePager(total) すると更新されなくなる pager.page
return toRefs(state) const { page } = usePager(total) できる page(自動で .value が外れる)
return { ...toRefs(state), next } const { page, next } = usePager(total) できる。関数や computed も一緒に返せる page

呼び出し側が分割代入することを前提にするなら、toRefs で返す形が扱いやすくなります。

PiniaのstoreToRefsとの違い

Pinia のストアも reactive オブジェクトなので、そのまま分割代入すると state の値は更新されなくなります。ストア用には storeToRefs が用意されています。toRefs との違いを出力で比べます。

import { toRefs, isRef, watchEffect, nextTick } from 'vue'
import { createPinia, setActivePinia, defineStore, storeToRefs } from 'pinia'

setActivePinia(createPinia())

const useCartStore = defineStore('cart', {
  state: () => ({ items: [], coupon: '' }),
  getters: { total: (state) => state.items.reduce((sum, i) => sum + i.price, 0) },
  actions: {
    add(item) { this.items.push(item) },
  },
})
const store = useCartStore()

console.log('=== storeToRefs(store) ===')
const refs = storeToRefs(store)
console.log('キー:', Object.keys(refs))
const { items, total } = refs
const { add } = store            // アクションはストアからそのまま取り出す

watchEffect(() => console.log(`合計 = ${total.value} 円 (${items.value.length} 件)`))
add({ name: 'ノート', price: 300 })
await nextTick()

console.log('=== toRefs(store) ===')
const all = toRefs(store)
console.log('キー:', Object.keys(all))
console.log('add は ref になる:', isRef(all.add), '/ typeof all.add.value:', typeof all.add.value)

console.log('=== そのまま分割代入 ===')
const { coupon } = store
store.coupon = 'AUTUMN'
console.log('coupon:', JSON.stringify(coupon), '/ store.coupon:', store.coupon)
console.log('storeToRefs の coupon:', refs.coupon.value)

実行結果:

=== storeToRefs(store) ===
キー: [ 'items', 'coupon', 'total' ]
合計 = 0 円 (0 件)
合計 = 300 円 (1 件)
=== toRefs(store) ===
キー: [
  '_hmrPayload', '_customProperties',
  '_p',          '$id',
  '$onAction',   '$patch',
  '$reset',      '$subscribe',
  '$dispose',    'items',
  'coupon',      'add',
  'total',       '_hotUpdate'
]
add は ref になる: true / typeof all.add.value: function
=== そのまま分割代入 ===
coupon: "" / store.coupon: AUTUMN
storeToRefs の coupon: AUTUMN
比べる点 storeToRefs(store) toRefs(store)
含まれるもの state と getter だけ アクションや $patch などストアの全プロパティ
アクション 含まない ref に包まれる(add.value(...) と呼ぶことになる)
向く用途 ストアの分割代入 ストアには向かない
  • storeToRefs のキーは items・coupon・total の3つだけで、アクションの add は含まれていません。Pinia の公式ドキュメントにも、アクションやリアクティブでないプロパティは対象にしないと書かれています。
  • アクションは const { add } = store と、ストアからそのまま取り出します。 アクションはストアに結び付いているので、取り出しても動きます。
  • state をそのまま分割代入した coupon は、空文字のまま更新されていません。

toRef・toRefs・toValueの使い分け

判断表

状況 選ぶもの
reactive から複数のプロパティを取り出す toRefs
reactive から1つだけ取り出す toRef(obj, key)
省略可能なプロパティ、後から追加されるプロパティ toRef(obj, key)
ネストした値(state.user.name)を ref として渡す toRef(() => state.user.name)
親オブジェクトが差し替えられても追従させたい toRef(() => ...)
計算結果をキャッシュしたい computed
コンポーザブルの引数を、値・ref・getter のどれでも受け取りたい 型は MaybeRefOrGetter、読むときは toValue
コンポーザブルから状態を返す toRefs(state) を展開して返す
script setup の props(Vue 3.5 以上) そのまま分割代入
props を watch やコンポーザブルに渡す () => count と getter で渡す
Pinia のストアの state・getter storeToRefs
Pinia のストアのアクション ストアからそのまま分割代入

つまずきやすい点と直し方

起きること 理由 直し方
分割代入した値が画面で更新されない 値のコピーになっている toRefs(state) を通す
toRefs で取り出した変数が undefined 呼び出し時点でプロパティが無い toRef(state, 'key') を使う
toRefs() expects a reactive object と警告が出る 普通のオブジェクトを渡している reactive() で包んでから渡す
ref の中身を取り出した ref が、差し替え後に追従しない 差し替え前のオブジェクトにつながっている toRef(() => user.value.name) にする
getter 形式の toRef に代入できない 読み取り専用 書き込みが必要なら toRef(obj, key) を使う
watch(count, ...) がコンパイルエラーになる 分割代入した props を直接渡している watch(() => count, ...) にする
storeToRefs の戻り値にアクションが無い state と getter だけが対象 アクションは store から取り出す
スクリプトで .value を付け忘れる toRefs の戻り値は ref TypeScript の型チェックで見つける

よくある質問

Q. toRefとtoRefsはどう使い分けますか?

A. 複数のプロパティをまとめて分割代入するなら toRefs、1つだけ取り出す場合や、存在しないかもしれないプロパティを扱う場合は toRef です。toRefs は呼び出した時点で存在するプロパティだけを対象にします。

Q. toRefsで取り出したrefに書き込むと、元のオブジェクトも変わりますか?

A. 変わります。toRefs と toRef(obj, key) の ref は元のプロパティとつながっているため、.value への代入は元のオブジェクトへの代入になります。getter 形式の toRef だけは読み取り専用です。

Q. Vue 3.5ではpropsにtoRefsを使わなくてよいのですか?

A. script setup の中で使うだけなら、分割代入のままでリアクティブに扱えます。watch やコンポーザブルに渡すときは () => count と getter で包みます。toRefs(props) も引き続き使えるので、既存のコードを書き換える必要はありません。

Q. refに入れたオブジェクトにもtoRefsは使えますか?

A. toRefs(user.value) のように .value に対して使えます。ただし user.value を丸ごと差し替えると、取り出した ref は差し替え前のオブジェクトにつながったままです。差し替えが起きる場合は getter 形式の toRef を使います。

まとめ

  • reactive をそのまま分割代入すると値のコピーになり、元の変更が伝わりません。 toRefs を通すと、元とつながった ref として取り出せます。
  • toRefs は複数まとめて、toRef(obj, key) は1つだけ取り出します。どちらも読み書きの両方が元に反映されます。
  • 存在しないプロパティには toRefs が使えません。 toRef なら後から追加されても追従します。
  • getter 形式の toRef(3.3以上)は読み取り専用で、ネストした値や props をコンポーザブルに渡すときに使います。受け取る側は toValue で読みます。
  • Vue 3.5 の props 分割代入はリアクティブなまま使え、watch に渡すときは getter で包みます。
  • コンポーザブルは toRefs(state) を展開して返すと、呼び出し側で分割代入できます。型も保たれます。
  • Pinia のストアは storeToRefs を使い、アクションはストアからそのまま取り出します。

参考

  • Vue.js 公式 API リファレンス「リアクティビティー API: ユーティリティー」 https://ja.vuejs.org/api/reactivity-utilities
  • Vue.js 公式ガイド「プロパティ」 https://ja.vuejs.org/guide/components/props
  • Pinia 公式ドキュメント「Defining a Store」 https://pinia.vuejs.org/core-concepts/

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Vue toRef・toRefsの使い方|reactive分割代入の注意」

株式会社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で一緒に開発しながら、 成長を楽しみませんか?