Skip to content
intermediate Phase 3 · Reactive Refs and the Composition API

Common Reactivity Pitfalls

Avoid lost reactivity from destructuring, spreading, and new properties.

40m
0 problems
Topic Progress 0%

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?

Question 1 options

2. In Vue 3, what happens with `const s = reactive({ a: 1 }); s.fresh = 2`?

Question 2 options

3. const cart = useCartStore(); const { items } = cart; cart.items.push(x). Does the UI update?

Question 3 options

4. const state = reactive({ count: ref(0), list: [ref(1)] }). What do state.count and state.list[0] give you in script?

Question 4 options

Flashcards

Question

Name the five classic ways reactive state silently detaches from its consumers.

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?

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.

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?

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'.

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.

Continue Learning

Before You Start

Related Reading