The Silent Cut Club: Five Ways to Detach State from Its Source
Every reactivity bug in a Vue 3 app has the same anatomy: the update happens somewhere your reactive system cannot see, or the component reads a copy that was made before the update. The five detach patterns below account for nearly all of them, and none produce an error — that is the entire problem.
1. Destructuring a reactive object. Covered as the headline of the previous topic, so only the shape here: const { email, valid } = form stores snapshots; toRefs(form) stores linked refs. The same applies to props: const { title } = defineProps<...>() reads once (Vue 3.5+ tracks destructured props reactively in more cases, but the portable, version-safe pattern is reading props.title or using toRefs-style access).
2. Spreading — the copy that looks alive.
const form = reactive({ email: '', valid: false })
const draft = { ...form } // plain object, plain values — INERT
draft.valid = true // no trigger; nothing watches this object
form.valid // still false — the copy diverged silently
Spreading reads every property through the proxy getter once and builds a plain object. It is the right tool for "give me a value snapshot to send to the server" and the wrong tool for "derive a live view". Live derivation is computed (re-evaluated on change) or reactive/ref derived state initialized from the source.
3. Cloning out of reactivity. JSON.parse(JSON.stringify(state)) and structuredClone(state) both cross the boundary into plain data by design — fine for payloads, fatal when the clone is what the template renders. If a component must render a frozen snapshot (diff view, audit trail), name it snapshot and make the intent reviewable.
4. Extracting instead of passing the container. A helper that needs to mutate or watch state must receive the ref/reactive object, not its current value:
function resetTimer(total: Ref<number>) { total.value = 0 } // link kept
function resetTimerBroken(total: number) { total = 0 } // rebinding a parameter
The mirror image happens with watchers: watch(() => state.x, ...) subscribes to the property; watch(state.x, ...) subscribes to the current value — when state.x is itself a ref, the second form is usually what you meant, and when it is a plain value, the second form silently watches a constant forever.
5. New-property folklore — the one that changed. The widely repeated Vue 2 rule — adding a brand-new property with bracket syntax bypasses tracking; use Vue.set — no longer applies in Vue 3. The proxy intercepts state.newKey = value like any other set, and iteration dependencies re-render anything that iterated the object's keys:
const state = reactive({ a: 1 })
state.fresh = 2 // Vue 3: tracked, renders — no Vue.set equivalent needed
Vue.set/Vue.delete were removed (they exist only as deprecated compat shims). What does survive from the folklore: adding properties to the target of a shallowRef does not trigger (shallow means exactly that — only .value replacement triggers), and mutating a non-reactive plain object never triggers because there is no proxy at all. Audit old blog posts against the runtime you actually run.
The diagnostic that cuts through all five: watch the source, mutate, observe. Select the component in devtools, change the value at its source of truth, and check whether the source itself updates. If it does but the UI doesn't, a copy sits between them — grep for the destructuring, spread, or extraction. If the source doesn't update, the mutation isn't reaching the proxy — check the next chapter's unwrapping map.
The Unwrapping Map: Where .value Is Required and Where It Isn't
state.count in a template and state.value.count in script — or is it state.count in script too? The confusion is systematic, and the resolution is a map of four contexts, each with one rule.
| Context | Ref handling | Write form |
|---|---|---|
| Script, holding the ref | not unwrapped | count.value = 1 |
| Template, top-level setup binding | unwrapped | count = 1 (compiler rewrites) |
| Property of a reactive object | unwrapped on access | state.count = 1 |
| Element of an array / Map / Set | not unwrapped | state.list[0].value = 1 |
Rows three and four are the trap factory. A reactive object's property access passes through a getter that unwraps a nested ref, so reactive({ count: ref(0) }) gives you state.count as a plain number everywhere — script included. Put the same ref in an array and it stays a container: state.items[0] is a Ref, and state.items[0] = 5 stores the number 5 where a ref was expected, breaking every reader that calls .value. Arrays and Sets are documented non-unwrapping zones; keep refs out of them, or accept the .value at every access.
Pinia brings the same map to stores, and this is where most "my store stopped updating" tickets come from. A store's state is a reactive object, but a store itself is not — it is a plain object with a state property holding the reactive one. Destructuring therefore detaches:
const cart = useCartStore()
const { items, total } = cart // INERT copies — the classic bug
const { items, total } = storeToRefs(cart) // linked Refs — the fix
cart.items.push(item) // also fine: read through the store proxy
cart.$reset() // store methods always go through the proxy
The practical rule for stores: mutate through the store object (cart.add(item)), read through destructured refs (storeToRefs) or the store (cart.total), never destructure raw values off the store. Store actions and getters remain accessible by plain destructuring — functions and computed values carry no per-value link to lose; only state-like values need storeToRefs.
Two adjacent confusions round out the map. Props are not refs — they are values read from the parent's render scope, so there is no .value and no unwrapping; mutate never, receive updates when the parent re-renders. And computed results behave like refs by the same rules: a top-level computed unwraps in the template, while one buried in an array does not — which is why const rows = computed(() => ...) at the top level is the default and return [computed(...)] from a helper is a trap.
If you remember one sentence from this chapter: .value is required exactly where no unwrap rule applies — in script on a directly-held ref, and on any ref nested inside an array or collection. Everything else is unwrapped by a getter or by the compiler, and the internals chapter explains why those getters exist at all.
Props deserve one more row on the map because they imitate unwrapping without being refs. A parent passes values down; the child reads props.title — no .value anywhere, because there is nothing to unwrap, and mutation attempts fail for the same one-way-flow reason they always have. What confuses people is v-model on a component: it looks like a writable ref from the parent's side, but it is a prop plus an emit pair — the child receives a value and requests changes; the macro hides the plumbing, not the direction of the flow.
Debugging Dead Updates: A Five-Minute Triage Sequence
The symptoms are always the same — a value changed, the UI did not. The causes, however, live in one of five layers, and a fixed order of checks finds them fast.
Step 1: Is the state actually reactive? Module-scope declarations are shared across every app instance and touched by no proxy:
let cache = {} // NOT reactive, ever — updates vanish
const cache = reactive({}) // the reactive version
Similarly, objects created before their container became reactive — a plain object stored into a shallowRef, or state assembled with Object.create-style tricks — may sit outside the proxy. Check in devtools: does the Setup state panel show the value you expect after your mutation?
Step 2: Does the mutation reach the proxy? The four bypasses: a destructured snapshot, a spread copy, a toRaw/structuredClone extract, or a ref held by value (const local = count.value). In each case your code mutates a different object than the one the template reads. This is the step that catches the "works until someone refactored it into a helper" regressions.
Step 3: Does the template read through the proxy? A template can only re-render against the reactive source. A component receiving :total="cartTotal" where the parent computed cartTotal = cart.total once at setup (instead of computed(() => cart.total)) renders the initial snapshot forever — the link was cut in the parent's script, one layer away from the visible bug. Trace the prop chain upward until you find where the reactive read disappeared.
Step 4: Is it an unwrapping/.value confusion? Console shows RefImpl where a value was expected (nested ref in an array, or reading .value on something already unwrapped like a store property), or a write like state.items[0] = 5 replaced a ref with a primitive. The unwrapping map from the previous chapter names the fix.
Step 5: Is anything watching? Reactivity notifies dependents; if no effect ever read the value, there is nothing to notify — and that is correct. console.log(state.x) in a timer, a setInterval closure holding a snapshot, an event listener attached to a detached node: none register dependencies. Wire the behavior to a watch or move the read into a computed/render so the system can see it.
Two tools make this sequence fast in practice. Devtools' Setup state pane shows live values per component — compare with your expectation; a mismatch localizes the bug to that component's scope in seconds. watchEffect with a probe temporarily turns invisible reads into a log: watchEffect(() => console.log('count is', count.value)) re-runs on every change, proving whether the trigger fires at all; if it logs and the template doesn't update, the problem is between the state and the render (usually a copy); if it doesn't log, the problem is upstream of the trigger (usually a bypass). Remove the probe before commit.
With the failure modes mapped, the last piece is the machinery that makes both the tracking and these gaps inevitable: dependency collection and trigger dispatch. How Vue Tracks Reactivity opens the box.
Keep one meta-lesson from the triage: the framework never lies about what it tracks — devtools shows the live values, the probe shows the triggers, and the unwrapping map explains the rest. Bugs that look like framework misbehavior are almost always a copy introduced in the name of cleanliness — a helper that just wanted the value — so the review habit worth adopting is flagging every extraction of data from reactive state and asking whether the caller needed the container instead.
Quiz
1. const form = reactive({ email: '', valid: false }); const draft = { ...form }; draft.valid = true. What is the result?
2. In Vue 3, what happens with `const s = reactive({ a: 1 }); s.fresh = 2`?
3. const cart = useCartStore(); const { items } = cart; cart.items.push(x). Does the UI update?
4. const state = reactive({ count: ref(0), list: [ref(1)] }). What do state.count and state.list[0] give you in script?
Flashcards
Question
Name the five classic ways reactive state silently detaches from its consumers.
Click to reveal answer
Answer
1) Destructuring a reactive object (snapshot). 2) Spreading or Object.assign-copying it (inert plain object). 3) Cloning (JSON/structuredClone) used as render state. 4) Passing a value where the helper needs the container (or watching a constant). 5) Vue 2 folklore — Vue.set for new keys — which no longer applies in Vue 3 (though shallowRef targets and plain objects remain genuinely untracked).
Question
Which Vue 2 reactivity rules are obsolete in Vue 3, and which behaviors remain real limits?
Click to reveal answer
Answer
Obsolete: new-property addition needing Vue.set (proxy tracks it), index assignment not triggering (tracked), sort/reverse warnings (legal and triggering). Real: mutating a shallowRef's target (only .value replacement triggers), state that was never reactive (module-scope plain objects), and the array/Map/Set non-unwrapping rule for nested refs.
Question
State the unwrapping map for refs.
Click to reveal answer
Answer
Directly held in script: no unwrap — .value. Top-level setup binding in template: unwrapped (compiler rewrites). Property of a reactive object: unwrapped on access. Element of an array/Map/Set: NOT unwrapped — .value needed. Computed follows the same map as ref. Props are plain values — never .value.
Question
How do you read and mutate Pinia store state correctly?
Click to reveal answer
Answer
Mutate through the store object or its actions (cart.items.push / cart.add()) — the proxy path. For destructured reads use storeToRefs(cart) (linked refs) or read cart.total directly. Plain destructuring of state values copies snapshots and dies; functions (actions/getters) destructure freely. cart.$reset()/$patch() also go through the proxy.
Question
Give the five-layer triage for 'the value changed but the UI didn't'.
Click to reveal answer
Answer
1) Is the state reactive at all (module-scope plain object? shallowRef target?) 2) Does the mutation reach the proxy (destructure/spread/toRaw/snapshot bypass?) 3) Does the template's prop chain read through the proxy (parent captured a value instead of a computed?) 4) Unwrapping/.value confusion (RefImpl in output, ref replaced in an array) 5) Is any effect watching (console reads and timers register nothing). Probe with watchEffect(() => console.log(x.value)).
Revision Notes
Key Takeaways
- 1. All reactivity detach bugs share one anatomy: a copy sits between the source and the reader — destructuring, spreading, cloning, and value-passing are the copy machines
- 2. Vue 3's Proxy retired the Vue.set/index-assignment folklore; the genuinely untracked cases are shallowRef targets, never-reactive objects, and refs nested in arrays
- 3. Unwrapping is contextual: template top-level and reactive-object properties unwrap; directly-held script refs and array elements do not
- 4. Pinia demands the same discipline — storeToRefs for destructured reads, mutation through the store proxy, actions and getters destructurable
- 5. Triage in a fixed order (reactive? → proxy reached? → template chain? → unwrap? → watched?) and use watchEffect probes plus the devtools Setup pane to localize in seconds
Interview Tips
- • "My template stopped updating after a refactor — how do you debug it?" — walk the triage: check the state is reactive, the mutation reaches the proxy, the template chain reads it, unwrap isn't confused, something watches; mention the watchEffect probe
- • "Is Vue.set needed for new properties?" — no, Vue 3's proxy tracks additions; explicitly retiring the Vue 2 rule (and naming the shallowRef exception) shows version-accurate depth
- • "Why does destructuring my Pinia store break?" — store state is reactive but the store object isn't; storeToRefs or store access; contrast with actions destructuring fine
- • "Where is .value required?" — give the full unwrapping map, especially the array non-unwrapping rule — candidates who only know 'templates unwrap' miss half the map
Cheat Sheet
Reactivity Pitfalls (and the Vue 3 corrections)
The silent cuts — none of them error:
const { email } = form // snapshot → toRefs(form)
const draft = { ...form } // inert copy → computed / reactive source
JSON.parse(JSON.stringify(state)) // clone for payloads only, never for render state
function f(total: number) { total = 0 } // pass the Ref, not the value
Folklore audit:
| Old Vue 2 rule | Vue 3 reality |
|---|---|
new key needs Vue.set |
proxy tracks state.fresh = x directly |
arr[i] = x doesn't trigger |
tracked |
sort/reverse warn |
legal, trigger normally |
| — | still real: shallowRef targets, plain module-scope objects, array/Map nested-ref non-unwrapping |
Unwrapping map: script-held ref → .value · template top-level → unwrapped · reactive-object property → unwrapped · array/Map/Set element → not unwrapped · props → plain values, never .value.
Pinia: mutate via store/actions · read via storeToRefs(store) or the store object · never destructure raw state values · actions/getters destructure fine.
Triage order: state reactive? → mutation reaches proxy? → template chain reads proxy? → unwrap/.value confusion? → is anything watching? · Probe: watchEffect(() => console.log(x.value)) · Devtools Setup pane = live per-component values.