skip to content

Reactivity System

Vue 3 tracks reads through Proxies and ref accessors: ref vs reactive, shallow and raw opt-outs, the update queue and effect scopes. Interviewers test why destructuring silently breaks tracking.

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

explore

questions

29

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, what is the difference between ref() and reactive(), and why do the Vue docs recommend ref() as the primary API?

level: juniorimportance: must knowfreq 84%

basics

~20 s

ref() wraps any value in an object whose .value getter tracks and setter triggers; reactive() returns a deep Proxy of an object itself. The docs prefer ref() because reactive() rejects primitives, cannot be replaced wholesale, and loses tracking when destructured.

open as a page

In a Vue 3 component, why does measuring a list right after pushing an item return the old height, and how does nextTick() help?

level: juniorimportance: must knowfreq 75%

basics

~20 s

Vue 3 does not patch the DOM when state changes; the write queues the component's update job, which runs in a microtask flush later. Awaiting nextTick() after the push waits for that flush, so the measurement sees the new item.

open as a page

In a Vue 3 `<script setup>` component, why does `count++` on a plain `let count = 0` not update the template, while `count.value++` on a `ref` does?

level: juniorimportance: must knowfreq 70%

basics

~20 s

Vue can only see reads and writes that pass through a property access it controls. A ref's .value getter calls track() and its setter calls trigger(), so the render effect subscribes and re-runs; a plain variable's reads and writes are invisible to Vue.

open as a page

In Vue 3, a click handler assigns three different refs that one component renders; how many times does that component re-render, and why?

level: middleimportance: must knowfreq 60%

basics

~20 s

Once. Each write triggers the component's render effect, but its update job is already marked as queued after the first, so the later triggers are ignored; the single microtask flush re-renders once with all three new values.

open as a page

In Vue 3, how does the reactivity system know exactly which effects to re-run when one property of a `reactive()` object changes?

level: middleimportance: must knowfreq 58%

basics

~20 s

The proxy's get trap calls track(target, key), linking the running effect to a dep stored under targetMap, then target, then key. The set trap calls trigger(target, key) only when the value changed or the key is new, notifying just that key's subscribers.

open as a page

In a Vue 3 `<script setup>` component, why are the watchers you create during setup stopped automatically when the component unmounts?

level: juniorimportance: should knowfreq 45%

basics

~20 s

Each Vue 3 component instance owns an effect scope that is active while setup runs, so watchers created synchronously there register with it; unmounting calls that scope's stop(), which stops them and runs its onScopeDispose callbacks.

open as a page

In Vue 3, what happens when you pass a number to reactive(), and why do primitive values need ref() instead?

level: juniorimportance: should knowfreq 45%

basics

~20 s

reactive(42) returns 42 unchanged and warns in development, because a Proxy can only wrap an object. ref() boxes any value in an object whose .value getter and setter do the tracking, so primitives become reactive.

open as a page

In Vue 3, what does wrapping state in `readonly()` do, and does the readonly view still update when the original changes?

level: juniorimportance: should knowfreq 42%

basics

~20 s

readonly() returns a deep read-only proxy over the original object or ref: writes are refused with a development warning, and nested objects read through it are read-only too. Wrapped around reactive state it still tracks, so it reflects every change to the original.

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

In Vue 3, how does onScopeDispose() differ from onUnmounted(), and what does getCurrentScope() let you check before calling it?

level: middleimportance: should knowfreq 30%

basics

~20 s

onUnmounted() needs an active component instance; onScopeDispose() needs only an active effect scope, so it works in a component and in a manual effectScope(). getCurrentScope() returns that scope or undefined, letting code register cleanup only when one exists.

open as a page

In Vue 3, what does effectScope() return, and how do its run() and stop() methods dispose a group of effects together?

level: middleimportance: should knowfreq 35%

basics

~20 s

effectScope() returns an EffectScope object. Effects created inside scope.run(fn) register with it and run() returns fn's result; scope.stop() stops them all, runs onScopeDispose callbacks and stops nested scopes, after which the scope is inactive for good.

open as a page

In Vue 3, why is `reactive(obj) === obj` false, and what do repeated reactive() calls on the same object or its proxy return?

level: middleimportance: should knowfreq 50%

basics

~10 s

reactive(obj) returns a Proxy wrapping obj, a different object, so === is false. Vue caches one proxy per target: calling reactive(obj) again returns that same proxy, and reactive(proxy) returns the proxy itself.

open as a page

In Vue 3, how does a reactive Map of sessions track get(), set() and iteration, and what breaks when keys mix raw and proxy objects?

level: middleimportance: should knowfreq 30%

basics

~20 s

Vue 3 swaps a reactive Map's methods for instrumented ones: get and has track one key, iteration and size track the whole map, and set, delete and clear trigger. Keys are matched raw-or-proxy, but storing both forms of one object creates two entries.

open as a page

In Vue 3, where are refs unwrapped automatically, and where must you still write .value - templates, reactive objects, arrays or Maps?

level: middleimportance: should knowfreq 55%

basics

~20 s

Vue 3 unwraps top-level refs in templates and refs stored as properties of a deep reactive object. It does not unwrap refs held in reactive arrays by index, in Map or Set collections, or nested inside plain objects.

open as a page

In Vue 3, what does one scheduler flush run, in what order, and when does an awaited nextTick() resume relative to it?

level: middleimportance: should knowfreq 40%

basics

~20 s

A Vue 3 flush runs the main queue sorted by component id, with pre-flush watcher jobs just before their component's update, then the post-flush callbacks such as mounted and updated hooks; nextTick() returns that flush's promise, so it resumes after all of it.

open as a page

In Vue 3, what does `shallowRef()` track, and why does `list.value.push(item)` on one not update the view without `triggerRef()`?

level: middleimportance: should knowfreq 48%

basics

~20 s

shallowRef tracks only reads and writes of .value itself; the inner value is stored as-is, not proxied, so mutating inside it runs no trap and triggers nothing. Assign a new value to .value, or call triggerRef(ref) after the mutation, to notify dependents.

open as a page

In Vue 3.5, what does it mean that `computed()` is a lazy effect, and how does it avoid needless recomputation and re-renders?

level: middleimportance: should knowfreq 45%

basics

~20 s

A computed never runs its getter when a source changes; it only marks itself dirty and notifies its readers. It recomputes on the next .value read, and bumps its own version only if the result changed, so readers whose inputs are unchanged skip re-rendering.

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

A Vue 3 `<script setup>` profile form declares `let profile = reactive({ name: '', email: '' })`, then runs `profile = reactive(await loadProfile())`; why do the fields stay empty, and how do you fix it?

level: seniorimportance: should knowfreq 50%

basics

~20 s

Reassigning the variable is invisible to Vue: the render tracked properties of the first proxy, and the new proxy has no subscribers. Mutate the existing object with Object.assign(profile, data), or hold the form in a ref and assign profile.value.

open as a page

A Vue 3 app in development throws 'Maximum recursive updates exceeded'; what has the scheduler detected, and how do you track down the cause?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Vue 3's development scheduler counts each job's runs per flush and throws past 100: an effect keeps mutating state it depends on and re-queues itself. Find that write in the render, a hook or a watcher and make it converge.

open as a page

A Vue 3 component keeps a charting library's chart instance in `ref()`, and calls on it start throwing or slowing down - why, and how should it be held?

level: seniorimportance: should knowfreq 38%

basics

~20 s

ref() turns the instance into a deep reactive proxy, so library methods run with the proxy as this: private fields throw, identity checks fail, and internal reads and writes pay for tracking. Keep it in a plain variable, a shallowRef, or markRaw it.

open as a page

A Vue 3 `watchEffect` reads `details.value` only inside `if (expanded.value)`; while collapsed, changing `details` never re-runs it - why, and is that a bug?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Vue re-collects an effect's dependencies on every run and drops any it did not read. While expanded is false, details is not read, so it is not a dependency. That is correct: the effect still tracks expanded, and re-subscribes to details once the branch runs.

open as a page

In Vue 3, what is the difference between `markRaw()` and `toRaw()`, and when is each the right tool?

level: middleimportance: nice to knowfreq 30%

basics

~20 s

markRaw(obj) flags an object so Vue never wraps it in a proxy, even inside reactive state. toRaw(proxy) returns the original object behind an existing proxy, for short untracked reads or writes. markRaw prevents proxying up front; toRaw steps around a proxy after the fact.

open as a page

How would you build a debounced ref with Vue 3's `customRef`, and what do the `track` and `trigger` arguments of its factory do?

level: middleimportance: nice to knowfreq 25%

basics

~20 s

customRef takes a factory that receives track and trigger and returns get and set. Call track() in get so readers subscribe; in set, restart a timer and only when it fires store the value and call trigger(), so readers update once typing pauses.

open as a page

In a Vue 3 app, a mouse-position tracker is shared by many components; how do you create its listener and effects outside any component and dispose them when the last user unmounts?

level: seniorimportance: nice to knowfreq 20%

basics

~20 s

Create the tracker once inside a detached effectScope(true), registering the listener's removal with onScopeDispose; count consumers, have each release through onScopeDispose in its own component scope, and stop the detached scope when the count reaches zero.

open as a page

As a Vue 3 lead, would you standardise a codebase on ref() only, and what does a ref-only convention cost?

level: principalimportance: nice to knowfreq 30%

basics

~20 s

Usually yes: ref-only gives one declaration rule that survives replacement and passing values around. It costs .value noise, forgotten-.value bugs in script, heavier grouped form state, and it does not remove proxies, since object values still go through reactive().

open as a page