Vue Reactivity Lost After Replacing a Reactive Object
Replacing a reactive object wholesale drops its proxy.
20+ years shipping production backend systems. Lessons pulled from things that broke in production.
- ✓How Vue 3 Proxy-based reactivity tracks reads and writes
- ✓ref() versus
reactive()basics and .value unwrapping - ✓Component props flow and shared state fundamentals
- Reassigning a reactive() variable replaces the proxy, so the template keeps watching the old object
- Mutate properties in place (state.user = data) or switch to ref() when you must replace wholesale
- Destructuring reactive state strips reactivity — spread with toRefs() before returning from setup
- Vue 3 tracks array index assignment natively, unlike Vue 2 which needed Vue.set
- Confirm with a button test: if the UI ignores updates after reassignment, the proxy was dropped
Imagine the post office forwards mail to your house with a redirect order. Tear it up and scribble a new address on an unfiled napkin, and letters keep going to the old house. Vue's reactive() is that redirect: the template watches the proxy wrapper, not your variable. Reassign the variable and the template still watches the old wrapper while data sits in a plain, unwatched object.
You fetch fresh data, assign it to your reactive object, and... nothing happens. The template shows stale values, computed properties don't recompute, and watchers stay silent — even though console.log proves the new data arrived. No error, no warning, just a UI frozen in the past. This silent failure is Vue's most disorienting reactivity trap.
The cause is the proxy swap. reactive() returns a Proxy wrapper around your object, and the template tracks that specific wrapper. When you write state = newData, your variable points at a fresh plain object while the template still observes the old proxy. Updates land in an object nobody watches. Destructuring does the same damage differently: const { name } = state pulls a raw value out of the proxy, severing the tracking link.
This article maps the exact rules: when to mutate versus replace, how ref() differs from reactive() on reassignment, why toRefs() exists, and how array index assignment changed between Vue 2 and Vue 3. You'll get a mental model that makes every variant of this bug recognizable on sight.
The Proxy Swap: Why Reassignment Silently Disconnects Templates
reactive() doesn't make your object reactive — it creates a Proxy that intercepts reads and writes, and returns that proxy to you. The template, computed properties, and watchers all subscribe to the proxy instance, not to your variable name. Your variable is just a pointer to the wrapper.
Reassignment (state = freshData) swings your pointer to a brand-new plain object while every subscriber still holds the old proxy. Writes to the new object bypass interception entirely: no trigger, no re-render, no watcher fire. Nothing throws because plain-object assignment is perfectly valid JavaScript — Vue simply isn't watching anymore. That's what makes this bug silent and maddening, and why log-based triage always misses it. Add a liveness probe (mutate a nested key, watch the template) to your debugging habits and the proxy swap becomes a five-minute diagnosis instead of a two-day incident.
The rule is absolute for reactive(): mutate properties, never replace the root. Object.assign(state, freshData) copies new values through the existing proxy, firing triggers for each changed key. For nested replacement, assign at the property level (state.user = freshUser) — replacing a nested value through the proxy is tracked, because the interception happens on the parent proxy you kept. Only the root variable must never be repointed.
ref vs reactive: Picking the Container That Allows Replacement
ref() and reactive() differ exactly where this bug lives. A ref wraps its value in an object with a .value property, and subscribers track the ref container — not the inner value. Assigning state.value = freshObject swaps the contents while the container (and every subscription) stays put. Replacement is safe by design.
reactive() has no container: your variable IS the tracked proxy, so repointing the variable abandons the tracked thing. That's why the guidance splits cleanly: use ref() for values you'll replace wholesale (server snapshots, selected records, fetched entities) and reactive() for state you'll mutate in place (forms, UI flags grouped together, nested config).
Note the ergonomics trade: ref requires .value in script (templates unwrap automatically), while reactive allows direct property access. Teams often pick reactive() for cleaner script code, then hit the reassignment trap on the first fetch-then-replace flow. When in doubt, default to ref() for fetched data — the .value tax is cheaper than a silent staleness incident. For objects holding many form fields mutated individually, reactive() plus Object.assign on refresh is the sweet spot most apps settle on. Pick per variable, document it, and move on.
ref() territory. Choosing reactive() for them optimizes for typing comfort and pays for it in staleness risk.ref() and assign .value. Mutate in place? reactive() plus Object.assign.Destructuring Destroys Tracking: toRefs and toRef to the Rescue
const { name } = state extracts the current value of name — a plain string — with no link back to the proxy. Rendering that snapshot never updates, and returning it from setup() or a composable spreads frozen copies across components. The spread variant return { ...state } is equally dead: it copies values, not reactivity. Both patterns look clean and fail silently.
toRefs() converts each property into a ref wired back to the source proxy: return { ...toRefs(state) } keeps every field live while allowing destructured consumption. Templates unwrap the refs automatically, so nothing downstream changes. For single properties, toRef(state, 'name') creates one linked ref without converting the whole object — ideal for passing one field into a composable while keeping two-way sync.
Apply the same discipline inside composables: accept and return refs, never bare values, at module boundaries. A composable that returns { name: state.name } freezes its output on first call; one returning toRefs keeps consumers live through every mutation. Make toRefs-at-the-boundary a review checklist item and this entire bug family stays out of your codebase for good. Audit existing composables once, fix the boundaries, and the snapshots stop shipping.
Array Index Assignment: the Vue 2 vs Vue 3 Divide
Vue 2's reactivity used Object.defineProperty, which cannot intercept index assignment — so arr[0] = x never triggered updates and you needed Vue.set(arr, 0, x) (or this.$set). This limitation is baked into thousands of Stack Overflow answers and veteran muscle memory. Vue 3's Proxy-based system intercepts index writes natively, making arr[i] = x fully reactive with no helper.
So when index assignment fails in Vue 3, the cause is never the index — it's the reference. You've replaced the array (state.items = newArr on a destructured copy), reassigned the reactive root, or you're mutating a non-reactive copy like a filtered array stored in a plain variable. Check the container first: is the array you index the same instance the template tracks? A quick identity log (isProxy from @vue/reactivity, or a mutation probe) answers in seconds.
For migrations, keep this.$set calls working (Vue 3 aliases them) but remove them opportunistically — they signal Vue 2 assumptions to future readers. Length mutations (arr.length = 0) and index writes both trigger in Vue 3, so clearing via items.length = 0 is safe. The one real array trap remaining is replacing methods' results: sort() and reverse() mutate in place (tracked), while filter() and map() return new arrays you must assign through the proxy.
Props, Stores, and the Patterns That Prevent the Whole Class
Local component state is only half the story. Props passed from a parent that replaced its reactive root arrive frozen — the child looks broken while the bug lives upstairs. When a child goes stale, resist fixing the child; trace the prop to the parent's assignment. Centralizing shared state in Pinia eliminates this class structurally: store state is mutated through actions, and wholesale replacement is unnatural enough that nobody writes it by accident.
Readonly boundaries deserve mention too. Passing readonly(state) to children that then try to mutate warns loudly rather than failing silently — prefer loud failures everywhere. And for form editing of store objects, edit a local reactive copy and commit via Object.assign back into the store, keeping one tracked source of truth.
Adopt three team rules and the bug class dies out. One: reactive() roots are never reassigned — only Object.assign or property writes. Two: composables return refs via toRefs, never snapshots. Three: fetched-and-replaced entities live in ref(), not reactive(). Enforce with review checklists and a lint rule against assigning to reactive-declared names, and staleness incidents stop appearing in your tracker.
ref(). If you remember one rule from this article, make it this one.A Five-Minute Audit That Finds Every Broken Link
Run this audit on any component that shows stale data. First, find the state declaration: reactive() means scan for = assignments to that name anywhere in the file — each one is a suspect. ref() means verify script reads use .value (a missing .value reads the container, silently passing the ref itself into logic). Second, check the return statement and composable boundaries for bare destructuring or spreading of reactive objects.
Third, probe liveness directly: add a temporary button that writes a nested key and watch the template. If the probe doesn't render, the proxy link is broken upstream of your data — stop debugging the fetch. Fourth, verify arrays by identity: log whether the mutated array is the tracked instance or a derived copy. Fifth, check children: stale props with a healthy child means the parent replaced its root.
Codify the audit as a test pattern. Mount the component, trigger the refresh path (save, refetch, filter), and assert the DOM reflects new values without remounting. This single test shape — refresh-then-assert-live — catches proxy swaps, frozen destructures, and stale props alike, and it runs in milliseconds. Add it to every component that fetches, and silent staleness becomes a CI failure instead of a support ticket.
The Settings Page That Swallowed Every Save for Two Days
reactive(), this replaced the proxy the template tracked with a plain object. Subsequent keystrokes mutated the plain object (so inputs still worked locally), but computed validation summaries and the sidebar preview — both bound to the original proxy — never updated again. Every re-save re-fetched and re-broke the link, so the page looked progressively more stale the more users tried to fix it.reactive() variables are never reassigned, only mutated.- reactive() state must be mutated, never replaced. One wholesale reassignment silently disconnects every template binding with zero console output.
- Silent bugs need interaction-style tests: save-then-assert-without-reload catches staleness that reload-based QA always misses.
- Shared mutable state belongs in a store. Hoisting it into Pinia removes the reassignment footgun instead of relying on discipline.
setup() for direct reassignment of reactive() variables (state = ...). Also check for destructuring (const { x } = state) before return. Either pattern disconnects the template. Confirm by adding a temporary button that mutates a nested key — if even that doesn't render, the proxy link is broken, not the fetch.reactive() to ref() and replace via state.value = newData — ref's .value indirection survives replacement by design. Update template bindings (they unwrap automatically) and script reads (add .value). Keep nested mutation for the rest; only the replaced root needs ref.setup() or a composable kills updates| File | Command / Code | Purpose |
|---|---|---|
| SettingsForm.vue | <script setup> | The Proxy Swap |
| UserProfile.vue | <script setup> | ref vs reactive |
| useUser.js | const state = reactive({ name: '', email: '', role: 'viewer' }) | Destructuring Destroys Tracking |
| TaskList.vue | <script setup> | Array Index Assignment |
| SettingsForm.spec.js | test('save refreshes the preview without remounting', async () => { | A Five-Minute Audit That Finds Every Broken Link |
Key takeaways
reactive() proxy, not your variablereactive() state with Object.assign or property writes; hold replaced entities in ref() instead.Common mistakes to avoid
5 patternsAssigning fetch results wholesale into reactive() state
ref() and assign .value.Returning { ...state } or destructured fields from setup()
Applying Vue 2 $set workarounds (or omitting needed ones) across versions
Debugging the child when the parent broke the prop
Testing data flows only with full remounts
Interview Questions on This Topic
Why does state = newData break reactivity when state was created with reactive()?
Frequently Asked Questions
20+ years shipping production backend systems. Lessons pulled from things that broke in production.
That's Vue. Mark it forged?
5 min read · try the examples if you haven't