Home › Frontend › Vue Reactivity Lost After Replacing a Reactive Object
Intermediate 5 min · September 23, 2026

Vue Reactivity Lost After Replacing a Reactive Object

Replacing a reactive object wholesale drops its proxy.

N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Lessons pulled from things that broke in production.

Follow
✓ Production
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
Before you start⏱ 11 min
  • ✓How Vue 3 Proxy-based reactivity tracks reads and writes
  • ✓ref() versus reactive() basics and .value unwrapping
  • ✓Component props flow and shared state fundamentals
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • 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
✦ Definition~90s read
What is Vue Reactivity Lost After Object Reassignment?

Lost reactivity means Vue's dependency tracking no longer observes your data, so mutations stop triggering re-renders. The mechanism is Vue 3's Proxy system: reactive() wraps your object in a Proxy that intercepts get and set operations, recording which effects depend on which keys.

★
Imagine the post office forwards mail to your house with a redirect order.

Templates, computed properties, and watchers subscribe to that specific proxy instance. Anything that bypasses the proxy — a replaced root object, a destructured snapshot, a copied array — mutates invisible data.

Three patterns cause nearly every case. Wholesale replacement (state = data) abandons the tracked proxy for a plain object. Destructuring (const { name } = state, or return { ...state }) extracts untracked values that never update. Reference confusion with arrays mutates a copy instead of the tracked instance.

Vue 2 developers face a fourth: index assignment (arr[i] = x) was never reactive under defineProperty and needed Vue.set, while Vue 3 tracks it natively — so version-mismatched habits misdiagnose the real break.

The fixes mirror the causes. Mutate reactive() roots in place with Object.assign or property writes. Hold wholesale-replaced entities in ref(), whose .value container survives replacement. Expose state across boundaries with toRefs() and toRef() instead of snapshots.

Centralize shared state in Pinia so mutation is the only natural operation. And verify with refresh-without-remount tests, because this bug class is silent — no error, no warning, just a UI stuck in the past.

Plain-English First

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.

SettingsForm.vueJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
<script setup>
import { reactive } from 'vue'

const state = reactive({ name: '', theme: 'light' })

async function save() {
  const res = await fetch('/api/settings')
  const fresh = await res.json()
  // BAD: state = fresh (drops the proxy)
  Object.assign(state, fresh) // GOOD: mutates through the proxy
}
Try it live
📊 Production Insight
This bug produces zero console output, so it survives every log-based triage. When data arrives but the UI ignores it, check for reassignment before checking the network.
🎯 Key Takeaway
The template tracks the proxy, not your variable. Mutate through it with Object.assign; never repoint the root.

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.

UserProfile.vueJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
<script setup>
import { ref, onMounted } from 'vue'

// ref survives wholesale replacement
const user = ref({ name: '', email: '' })

onMounted(async () => {
  const res = await fetch('/api/me')
  user.value = await res.json() // GOOD: container stays tracked
})
Try it live
📊 Production Insight
Fetched entities replaced on refresh are ref() territory. Choosing reactive() for them optimizes for typing comfort and pays for it in staleness risk.
🎯 Key Takeaway
Replace wholesale? Use 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.

useUser.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
import { reactive, toRefs, toRef } from 'vue'

const state = reactive({ name: '', email: '', role: 'viewer' })

export function useUser() {
  // BAD: return { ...state } — frozen snapshots
  // GOOD: every field stays a live ref
  return { ...toRefs(state), nameRef: toRef(state, 'name') }
}
Try it live
📊 Production Insight
Composable boundaries are where destructuring damage multiplies: one frozen return value silently breaks every consuming component at once.
🎯 Key Takeaway
Never return bare destructured reactive values. Spread toRefs() at every boundary that exposes state.

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.

TaskList.vueJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
<script setup>
import { reactive } from 'vue'

const state = reactive({ tasks: ['a', 'b', 'c'] })

function renameFirst(name) {
  state.tasks[0] = name // reactive in Vue 3, no $set needed
}

function clear() {
  state.tasks.length = 0 // also tracked via the proxy
}
Try it live
📊 Production Insight
Version-confusion wastes hours: engineers apply Vue 2 workarounds to Vue 3 code and vice versa. Pin the Vue version in the bug report template to skip this detour.
🎯 Key Takeaway
Vue 3 tracks index assignment natively. When array writes don't render, suspect a replaced reference, not the index.

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.

💡Never Reassign a reactive() Root
state = newData looks harmless and throws nothing, but it orphans every template binding. Mutate with Object.assign or switch the state to ref(). If you remember one rule from this article, make it this one.
📊 Production Insight
Pinia adoption measurably reduces staleness bugs because stores make mutation the path of least resistance and replacement awkward.
🎯 Key Takeaway
Centralize shared state in a store, return refs from composables, and forbid reactive root reassignment in review.

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.

SettingsForm.spec.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
import { mount } from '@vue/test-utils'
import { test, expect } from 'vitest'
import SettingsForm from './SettingsForm.vue'

test('save refreshes the preview without remounting', async () => {
  const wrapper = mount(SettingsForm)
  await wrapper.find('form').trigger('submit.prevent')
  await wrapper.vm.$nextTick()
  // fails if save() replaced the reactive root
  expect(wrapper.find('.preview').text()).toContain('dark')
})
Try it live
📊 Production Insight
The refresh-without-remount test is the highest-value reactivity test you can write. It converts silent staleness from a two-day incident into a red CI check.
🎯 Key Takeaway
Audit declarations, returns, and assignments; probe liveness; then lock it with refresh-without-remount tests.
● Production incidentPOST-MORTEMseverity: high

The Settings Page That Swallowed Every Save for Two Days

Symptom
Users reported that saving settings appeared to do nothing: the form kept showing old values after a successful save toast. Logs showed the save API succeeding — some users hit save five or six times, creating duplicate audit entries and redundant webhook deliveries. No console errors appeared because nothing threw; reactivity was just silently disconnected. The bug survived two days because QA tested with full page reloads, which re-mounted state and hid the staleness.
Assumption
The team suspected a caching layer serving stale reads after writes, and spent a day adding cache-busting headers to the settings API. When that changed nothing, they blamed the toast library for firing success before the write committed, and audited the promise chain. Both theories assumed the data was wrong. The data was right — the UI had simply stopped listening to it.
Root cause
The save handler assigned the server response wholesale: state = response.data. Since state was created with 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.
Fix
The handler now merges in place with Object.assign(state, response.data), preserving the original proxy. Shared settings state moved into a Pinia store so reassignment is impossible by construction. A regression test saves, then asserts the sidebar preview reflects the new values without remounting. The team also added a review rule: reactive() variables are never reassigned, only mutated.
Key lesson
  • 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.
Production debug guideFive steps to prove the proxy was dropped and reconnect the template to live data.5 entries
Symptom · 01
Template shows stale values after data clearly arrived (console.log proves it)
→
Fix
Search 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.
Symptom · 02
Some bindings update while others stay frozen after the same assignment
→
Fix
Map which bindings broke: destructured values freeze while direct state.key bindings keep working, which pinpoints a destructure site. For wholesale replacement, everything bound to the old proxy freezes at once. Check child components too — props passed from the replaced object go stale together, which scopes the break to the parent assignment.
Symptom · 03
You must replace the object (new identity, server snapshot) and can't mutate
→
Fix
Switch that state from 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.
Symptom · 04
Returning individual fields from setup() or a composable kills updates
→
Fix
Wrap with toRefs(): return { ...toRefs(state) } so each exposed field stays a live ref linked to the proxy. For single fields use toRef(state, 'key'). Never return bare destructured values from state you intend to keep reactive — they're snapshots, not bindings.
Symptom · 05
Array updates don't render — push works but index assignment doesn't (or vice versa)
→
Fix
In Vue 3, arr[i] = x is reactive natively, so suspect you've replaced the array reference or destructured it instead. In Vue 2 code, index assignment needs Vue.set (this.$set). Check the Vue version first — half of these reports are version-confusion — then verify you're mutating the tracked array, not a copy.
Lost Reactivity Causes Compared
Root CauseHow to ConfirmFixPrevention
Wholesale reassignment of a reactive() rootUI freezes after refresh; probe mutation doesn't renderObject.assign(state, data); use ref() for replaced entitiesReview rule: reactive roots are mutated, never assigned
Destructuring state before return or in composablesDirect bindings live but destructured ones freezeReturn { ...toRefs(state) }; use toRef for single keysRefs-at-boundaries checklist for every composable
Array written through a copy or replaced referenceIndex writes ignored though Vue 3 tracks them nativelyMutate the tracked instance; assign new arrays via proxyNever store derived arrays in plain variables
Parent replaced root, child props go staleHealthy child, frozen props; break appears after parent fetchFix parent assignment; centralize shared state in PiniaRefresh-without-remount tests on fetch paths
⚙ Quick Reference
5 commands from this guide
FileCommand / CodePurpose
SettingsForm.vue<script setup>The Proxy Swap
UserProfile.vue<script setup>ref vs reactive
useUser.jsconst state = reactive({ name: '', email: '', role: 'viewer' })Destructuring Destroys Tracking
TaskList.vue<script setup>Array Index Assignment
SettingsForm.spec.jstest('save refreshes the preview without remounting', async () => {A Five-Minute Audit That Finds Every Broken Link

Key takeaways

1
Templates track the reactive() proxy, not your variable
reassigning the root orphans every binding silently.
2
Mutate reactive() state with Object.assign or property writes; hold replaced entities in ref() instead.
3
Never return bare destructured values; expose live fields with toRefs() and toRef().
4
Vue 3 tracks array index assignment natively
suspect replaced references, not indices.
5
Trace stale child props to parent assignments; centralize shared state in Pinia.
6
Lock it in with refresh-without-remount tests on every fetch path.

Common mistakes to avoid

5 patterns
×

Assigning fetch results wholesale into reactive() state

Symptom
UI freezes on stale values after refresh with zero console errors; reload fixes it temporarily.
Fix
Merge with Object.assign(state, data), or hold replaced entities in ref() and assign .value.
×

Returning { ...state } or destructured fields from setup()

Symptom
Some bindings never update from the first render while direct state.key bindings work fine.
Fix
Return { ...toRefs(state) } so every exposed field stays a live ref linked to the proxy.
×

Applying Vue 2 $set workarounds (or omitting needed ones) across versions

Symptom
Index assignment bugs get 'fixed' with helpers that mask a replaced reference, or Vue 2 code stays broken.
Fix
Know your version: Vue 3 tracks indices natively. Debug the reference identity, not the index.
×

Debugging the child when the parent broke the prop

Symptom
Hours spent in a healthy child component while the parent's reassignment sits one file up.
Fix
Trace stale props upstream to the parent's assignment. Centralize shared state in a store.
×

Testing data flows only with full remounts

Symptom
Green tests, stale production: remount-based QA recreates proxies and hides every disconnect.
Fix
Assert refresh-without-remount: trigger save/refetch and check the live DOM in the same mount.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Why does state = newData break reactivity when state was created with re...
Q02SENIOR
When should you choose ref() over reactive() for an object?
Q03SENIOR
Why does destructuring reactive state break updates, and what fixes it?
Q04SENIOR
How does array index assignment differ between Vue 2 and Vue 3?
Q05SENIOR
How do you test for silent reactivity loss?
Q01 of 05JUNIOR

Why does state = newData break reactivity when state was created with reactive()?

ANSWER
reactive() returns a Proxy that subscribers track. Reassigning the variable points it at a fresh plain object while the template still watches the old proxy. Nothing throws — writes just land where nobody listens. Mutate with Object.assign instead.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Why is there no warning when reactivity is lost?
02
Can I mix ref() and reactive() in one component?
03
Does toRefs() deep-convert nested objects?
04
Is Object.assign enough for nested replacement?
05
Should I just always use ref() to avoid this?
06
Does Pinia eliminate this bug?
N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Lessons pulled from things that broke in production.

Follow
✓ Verified
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
🔥

That's Vue. Mark it forged?

5 min read · try the examples if you haven't

←
Previous
Vue Maximum Recursive Updates Exceeded
3 / 7 · Vue
Next
Vue Extraneous Non-Props Attributes Warning
→