skip to content

Destructuring & Ref Conversion

Destructuring a reactive object copies plain values out of tracking; toRef, toRefs and toValue keep the link. Interviewers probe props destructure in script setup and passing refs into functions.

part ofVue.jsoverview, primer and where to startread it →
on this pageshow

explore

questions

4

In Vue 3, why does `const { count } = reactive({ count: 0 })` stop updating, and how does `toRefs()` keep the link?

level: juniorimportance: must knowfreq 72%

answer

  1. destructuring reads once
  2. a primitive is copied out
  3. one ref per property
  4. each ref reads through the proxy

basics

~20 s

Destructuring reads state.count once through the proxy and copies the number into a local variable, which Vue cannot track or update. toRefs(state) instead returns an object of refs, one per property, whose .value reads and writes state[key] through the proxy, so the link survives destructuring.

solid answer

~40 s

Vue 3 tracks reactivity through property access on the proxy. `const { count } = state` performs one `get` on the proxy and assigns the resulting primitive to a local `const`; from then on nothing touches the proxy, so later reads of `count` are untracked and later changes to `state.count` never reach it. `toRefs(state)` returns a plain object whose every property is a ref linked to the source: reading `countRef.value` reads `state.count` through the proxy (tracked), and assigning it writes `state.count` (triggered). So `const { count } = toRefs(state)` gives a destructured `count` that is still live, and templates unwrap it like any top-level ref. `toRefs` covers only keys present when it is called, and in development it warns if handed a plain object.

code

vue · 18 lines
vue
<script setup lang="ts">
import { reactive, toRefs } from 'vue'

const state = reactive({ count: 0, step: 1 })

const { count: frozen } = state   // number 0, never updates
const { count, step } = toRefs(state) // live refs

function add() {
  state.count += state.step
}
</script>

<template>
  <button @click="add">add</button>
  <p>live: {{ count }} (step {{ step }})</p>
  <p>frozen: {{ frozen }}</p>
</template>

go deeper

for a junior

Recall that destructuring copies primitive values out of a reactive object and that toRefs() turns each property into a linked ref.

for a middle

Explain that toRefs returns property refs that read and write through the proxy, and why nested objects behave differently from primitives.

for a senior

Spot disguised copies in reviews - spreads, arguments, ref(state.x) - and know when toRef suits optional keys better than toRefs.

for a principal

Set conventions for how composables return state and how components consume it, so lost-reactivity bugs are designed out.

## Why destructuring disconnects A `reactive()` object is a proxy: Vue sees each property **read** through its `get` trap and each **write** through its `set` trap. Destructuring is just a sequence of reads: ```ts const state = reactive({ count: 0, user: { name: 'Ada' } }) const { count, user } = state // same as: const count = state.count; const user = state.user ``` After that line: - `count` holds the **number** `0`. It is a plain JavaScript binding with no accessor; reading it later is invisible to Vue, and `state.count++` elsewhere does not change it. - `user` holds the **reactive proxy** of the nested object, because deep reactivity wraps nested objects on access. `user.name = 'Grace'` is still a tracked write; but if someone replaces `state.user` with a new object, `user` keeps pointing at the old one. The docs list this as a limitation of `reactive()`: destructuring a primitive property into a local variable, or passing it into a function, loses the reactivity connection. ## What `toRefs()` returns `toRefs(state)` walks the object's current keys and creates one **property ref** per key: | Access | What happens | |---|---| | `refs.count.value` (read) | reads `state.count` through the proxy, so the reading effect is tracked | | `refs.count.value = 5` (write) | writes `state.count` through the proxy, so subscribers are triggered | | `state.count++` elsewhere | the next read of `refs.count.value` sees the new value | The refs hold no copy of the value; they are pointers to "property `count` of this object". Destructuring the returned plain object copies **refs**, and a ref copied into a local variable is still the same live object: ```ts const { count } = toRefs(state) count.value++ // state.count is now 1 ``` In a template, a top-level ref is unwrapped automatically, so `{{ count }}` renders the number. ## Where it fits 1. **Destructuring local state for readability** without losing updates. 2. **Returning state from a composable** as a plain object of refs, so callers can destructure what they need (the docs recommend returning refs for exactly this reason). 3. **Passing one property into a function** that expects a ref. ## Limits worth knowing - **Only existing keys.** `toRefs()` uses the keys present at call time. A property added later has no ref; use `toRef(state, 'key')`, which works even for a key that does not exist yet. - **Plain objects.** In development, `toRefs()` warns `toRefs() expects a reactive object but received a plain one.` The refs it would return read and write a plain object that nothing tracks. - **Arrays.** `toRefs()` on a reactive array returns an array of refs, one per index at call time. ## The same problem outside destructuring Any operation that copies a primitive out of the proxy has the same effect: `const n = state.count`, spreading `{ ...state }` into a new object, or passing `state.count` as an argument. The spread case is common in code that merges defaults, and it produces a plain snapshot every time. ## Common mistakes - Using `let { count } = state` and incrementing `count`, expecting `state.count` to follow. - Wrapping the copied value: `ref(state.count)` creates an **independent** ref initialised from the number, not a link. - Calling `toRefs()` on a ref instead of on a reactive object; to split a ref holding an object, call it on `ref.value`. ## A quick self-test Predict each result, given `const state = reactive({ n: 1, user: { name: 'Ada' } })`: 1. `const { n } = state; state.n = 2` - `n` is still `1`: a copied number. 2. `const { user } = state; state.user.name = 'Grace'` - `user.name` is `'Grace'`: `user` is the same nested proxy. 3. `const { user } = state; state.user = { name: 'Lin' }` - `user.name` is still `'Ada'`: the local points at the old object. 4. `const { n } = toRefs(state); state.n = 3` - `n.value` is `3`: the property ref reads through the proxy. If all four answers come easily, the model is in place: primitives and references are copied by destructuring, and only refs (or reading through the proxy each time) keep a live link.

  • In Vue 3, if you destructure a nested object out of reactive state, is it still reactive?
    Its contents are: the local variable holds the nested reactive proxy, so writing `user.name` is tracked. But the variable itself is a copy of the reference, so if the parent property is replaced with a new object, the local variable keeps pointing at the old one.
  • How does `toRef(state, 'key')` differ from what `toRefs()` gives you for that key?
    The ref is the same kind - a two-way link to one property. `toRef()` builds just one, and it also works for a key that does not exist yet, which `toRefs()` cannot cover because it only iterates the keys present when it is called.

Destructuring copies today's reading off a meter onto a sticky note; toRefs() hands you a remote display wired to the meter itself, so it always shows the current reading and can even turn the dial.

saying these in an interview costs you the question

  • Destructuring a reactive object gives reactive local variables.
  • ref(state.count) keeps the new ref in sync with state.count.
  • toRefs() copies the current values into new independent refs.
  • toRefs() also creates refs for properties added to the object later.
  • A nested object destructured from reactive state loses all reactivity.
open as a page

In Vue 3.5 `<script setup>`, why does a prop destructured from `defineProps()` stay reactive inside `computed`, yet `watch(id, cb)` on it fails?

level: middleimportance: should knowfreq 52%

basics

~20 s

Since Vue 3.5 the SFC compiler rewrites reads of a destructured prop into props.id, so reads inside computed or watchEffect are tracked. watch(id) would receive a value, not a source, so the compiler rejects it; write watch(() => id, cb).

open as a page

In Vue 3, how does `ref(state.foo)` differ from `toRef(state, 'foo')`, and when would you use `toRef(() => props.foo)` instead?

level: middleimportance: should knowfreq 38%

basics

~20 s

ref(state.foo) is an independent ref seeded with the current value. toRef(state, 'foo') is a two-way link to that property, even before it exists. toRef(() => props.foo), since 3.3, is a readonly ref calling the getter on each read.

open as a page

A Vue 3 `useCounter(start)` composable is called as `useCounter(props.start)`, and later changes to that prop are ignored - why, and how should it accept the argument?

level: seniorimportance: should knowfreq 45%

basics

~20 s

props.start is evaluated at the call site, so the composable receives a plain number with no link to the prop. Type the parameter MaybeRefOrGetter<number>, have callers pass () => props.start or a ref, and read it with toValue() inside a watch getter, computed or watchEffect.

open as a page