Fix: Vue Composition API Reactivity Lost — Destructured Props or Reactive Object Not Updating
Part of: React & Frontend Errors
Quick Answer
How to fix Vue Composition API reactivity loss — destructuring reactive objects, toRefs, storeToRefs, ref vs reactive, watch vs watchEffect, and template not updating.
The Problem
Destructuring a reactive object breaks reactivity — the template stops updating:
<script setup>
import { reactive } from 'vue'
const state = reactive({ count: 0, name: 'Alice' })
// Destructuring — BREAKS REACTIVITY
const { count, name } = state
function increment() {
state.count++
// count (local variable) is still 0 — it's a plain number, not reactive
}
</script>
<template>
<p>{{ count }}</p> <!-- Never updates -->
</template>Or destructured props stop being reactive:
<script setup>
const props = defineProps({ userId: Number, name: String })
// Destructuring props breaks reactivity
const { userId, name } = props // userId and name are plain values now
watch(() => userId, (newId) => { // Never triggers — userId is not reactive
fetchUser(newId)
})
</script>Or a Pinia store’s state loses reactivity after destructuring:
<script setup>
import { useUserStore } from '@/stores/user'
const store = useUserStore()
const { user, isLoading } = store // Loses reactivity
// user and isLoading are now plain values — template doesn't update when store changes
</script>Why This Happens
Vue’s reactive() creates a Proxy that intercepts every property read and write on the returned object. The Proxy is what Vue uses to know when to re-run a component’s render function. Reading state.count triggers the Proxy’s get trap, which registers a dependency. Writing state.count = 1 triggers the set trap, which notifies dependents and queues a re-render. The Proxy lives on the object itself — not on the values inside it.
When you destructure (const { count } = state), JavaScript reads state.count once, takes the resulting primitive, and binds it to a new local variable. The Proxy’s get trap fires for that one read and tracks a dependency, but the local count variable is now a plain number with no connection back to the Proxy. Subsequent reassignments to state.count update the original reactive object; the destructured count keeps its old value because it’s a separate variable. This is the same JavaScript semantics that breaks let { a } = obj; obj.a = 5; console.log(a) — a is still the original value. Vue’s reactivity can’t override how JavaScript variable bindings work.
The same trap applies to props, Pinia stores, and any composable that returns a reactive object directly:
const { count } = reactive({ count: 0 })—countis now a plainnumber, not a reactive reference.ref()objects are different —ref(0)wraps the value in a{ value }object. You access the value via.value. Destructuring arefitself is fine (the ref stays reactive).- Props —
propsfromdefinePropsis a reactive object. Destructuring it withconst { name } = propsloses reactivity (except in Vue 3.5+ with the reactive props destructure feature). - Pinia stores — state properties on store objects are reactive when accessed via
store.property, but not after plain destructuring.
In Production: Incident Lens
Reactivity loss is one of the more frustrating Vue bugs to triage because the page looks healthy. There’s no console error, no failed network request, no broken layout — the UI just stops reflecting the latest state. Users report the symptom in vague terms: “the price isn’t updating after I change the quantity,” “my notifications counter is stuck on 3 even though I have new messages.” From the dev side it looks like the data layer is broken, but the data is fine; the template is bound to a snapshot.
Blast radius. Reactivity loss is scoped to a single composable, component, or store consumer. The damage is bounded but the user experience is severe in that scope — users see stale prices, stale counts, stale availability. If the affected component is on a checkout page or an inventory display, the business impact is direct: customers see a price that no longer matches the cart total, or a “5 left in stock” badge that’s been zero for hours.
How it surfaces. Stale UI rarely fails synthetic checks because the page renders correctly on first load. It only breaks after an interaction that mutates state. Catch it with E2E tests that trigger a mutation and assert the rendered text changed, not just that the element exists. Add a Playwright assertion of the form await expect(page.locator('[data-testid="count"]')).toHaveText('1', { useInnerText: true }) after the action, not just toBeVisible().
Recovery sequence. When users report stale UI, open Vue DevTools on the affected component. If the component’s reactive state shows the new value but the template shows the old one, the binding is broken — usually a destructuring problem. If both DevTools and the template show the old value, the state update never reached the reactive object — usually a props mutation, a shallowRef on a deeply nested object, or an update done on a non-reactive copy. The fastest production mitigation is a manual key bump on the parent component (<Child :key="forceRefreshKey" /> with an incrementing key), which forces a full remount. Use this as a hotfix while you find the missing toRefs.
Postmortem preventive. Add a lint rule that flags destructuring assignments from reactive() calls, defineProps(), and Pinia stores. ESLint’s vue/no-setup-props-destructure (renamed to vue/no-setup-props-reactivity-loss in newer plugin versions) catches the props case. For Pinia, code review is the safety net — search for } = useXxxStore() patterns in PRs and require storeToRefs wherever state or getters are read.
Fix 1: Use toRefs() When Destructuring reactive
toRefs() converts each property of a reactive object into a separate ref, preserving reactivity:
<script setup>
import { reactive, toRefs } from 'vue'
const state = reactive({
count: 0,
name: 'Alice',
items: []
})
// WRONG — plain destructuring breaks reactivity
// const { count, name } = state
// CORRECT — toRefs wraps each property in a ref
const { count, name, items } = toRefs(state)
function increment() {
state.count++ // Update via original reactive object — OK
// OR
count.value++ // Update via the ref — also OK
}
</script>
<template>
<!-- count is a ref — Vue auto-unwraps refs in templates (no .value needed) -->
<p>{{ count }}</p>
<p>{{ name }}</p>
</template>When to use toRef() vs toRefs():
<script setup>
import { reactive, toRef, toRefs } from 'vue'
const state = reactive({ count: 0, name: 'Alice', age: 30 })
// toRef — extract ONE property as a ref
const count = toRef(state, 'count')
// count.value tracks state.count — reactive
// toRefs — extract ALL properties as refs
const { name, age } = toRefs(state)
// name.value tracks state.name — reactive
// age.value tracks state.age — reactive
// Spread into template composables pattern
function useCounter() {
const state = reactive({ count: 0, step: 1 })
function increment() { state.count += state.step }
// Return as refs — caller can destructure without losing reactivity
return { ...toRefs(state), increment }
}
</script>Fix 2: Fix Destructured Props
Use toRefs() on props to destructure while keeping reactivity:
<script setup>
import { toRefs, watch } from 'vue'
const props = defineProps({
userId: { type: Number, required: true },
name: String,
})
// WRONG — plain destructuring loses reactivity
// const { userId, name } = props
// CORRECT — toRefs preserves reactivity
const { userId, name } = toRefs(props)
// Now watch works — userId is a ref
watch(userId, async (newId) => {
await fetchUser(newId)
}, { immediate: true })
</script>
<template>
<!-- Both work — toRefs refs are auto-unwrapped -->
<h1>{{ name }}</h1>
<p>User #{{ userId }}</p>
</template>Vue 3.5+ — destructure props directly with reactivity (requires defineProps in <script setup>):
<!-- Vue 3.5+ — destructured props are reactive by default in <script setup> -->
<script setup>
// Vue 3.5+ enables reactive destructuring for defineProps
const { userId, name = 'Guest' } = defineProps({
userId: Number,
name: String,
})
// userId and name are reactive in Vue 3.5+ <script setup>
// Default values work too: name defaults to 'Guest' if not passed
</script>Fix 3: Fix Pinia Store Destructuring
Use storeToRefs() from Pinia to destructure state and getters:
<script setup>
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
const store = useUserStore()
// WRONG — plain destructuring loses reactivity
// const { user, isLoading, fullName } = store
// CORRECT — storeToRefs for state and getters only
const { user, isLoading, fullName } = storeToRefs(store)
// user, isLoading, fullName are refs — reactive
// Actions don't need storeToRefs — they're plain functions
const { fetchUser, logout } = store
// In template: user.name, isLoading, fullName (Vue auto-unwraps refs)
</script>
<template>
<div v-if="isLoading">Loading...</div>
<div v-else>
<h1>{{ user?.name }}</h1>
<p>{{ fullName }}</p>
<button @click="logout">Logout</button>
</div>
</template>Why storeToRefs instead of toRefs for Pinia:
// storeToRefs skips actions (functions) — only wraps reactive state/getters
// toRefs(store) would try to wrap actions as refs too — incorrect behavior
// Always use storeToRefs for Pinia storesFix 4: Understand ref vs reactive
Choosing between ref and reactive affects how you work with the data:
<script setup>
import { ref, reactive } from 'vue'
// ref — wraps any value (primitives, objects, arrays)
// Access/mutate via .value in <script>, auto-unwrapped in template
const count = ref(0)
const user = ref({ name: 'Alice', age: 30 })
count.value++ // Mutate in script
user.value.name = 'Bob' // Mutate nested property
user.value = { name: 'Carol', age: 25 } // Replace entire object
// reactive — for objects/arrays only
// Access directly (no .value), but can't replace the entire object
const state = reactive({ count: 0, items: [] })
state.count++ // Direct mutation
state.items.push('new item') // Array mutation
// state = { count: 1 } // WRONG — breaks reactivity (reassigns the variable)
// Guidelines:
// - Use ref for primitives (numbers, strings, booleans)
// - Use ref when you need to replace the entire value
// - Use reactive for complex objects when you only mutate properties
// - In practice, many teams use ref for everything — consistency beats optimization
</script>
<template>
<!-- No .value needed in templates — Vue auto-unwraps refs -->
<p>{{ count }}</p>
<p>{{ user.name }}</p>
<p>{{ state.count }}</p>
</template>Fix 5: Fix Watch Not Triggering
watch is strict about what it tracks:
<script setup>
import { reactive, ref, watch, watchEffect } from 'vue'
const state = reactive({ user: { name: 'Alice' }, count: 0 })
const countRef = ref(0)
// Watching a reactive property — use getter function
watch(
() => state.count, // Getter — tracks state.count
(newVal) => console.log('count changed:', newVal)
)
// WRONG — passing reactive object directly watches the reference (rarely useful)
// watch(state, ...) — only triggers on object replacement, not property changes
// Watching a ref directly (no getter needed)
watch(countRef, (newVal) => console.log('ref changed:', newVal))
// Watch nested property
watch(
() => state.user.name,
(newName) => console.log('name changed:', newName)
)
// Deep watch — tracks all nested changes (expensive for large objects)
watch(
() => state.user,
(newUser) => console.log('user changed:', newUser),
{ deep: true }
)
// watchEffect — automatically tracks all reactive dependencies it reads
watchEffect(() => {
// Reads state.count and countRef.value — both are tracked automatically
console.log('Effect:', state.count, countRef.value)
})
// Runs immediately AND whenever state.count or countRef changes
</script>Common watch mistake — watching a computed result instead of source:
<script setup>
const store = useUserStore()
const { userId } = storeToRefs(store)
// WRONG — userId.value is evaluated once (plain number), watch doesn't track
watch(userId.value, (newId) => fetchUser(newId)) // userId.value = 5 (number)
// CORRECT — pass the ref itself (not .value)
watch(userId, (newId) => fetchUser(newId)) // userId is a ref — reactive
</script>Fix 6: Reactivity with Arrays and Maps
Certain array and object operations require care with Vue reactivity:
<script setup>
import { reactive, ref } from 'vue'
const items = ref(['a', 'b', 'c'])
const state = reactive({ tags: ['vue', 'js'] })
// Array mutations that trigger reactivity:
items.value.push('d') // ✓ Reactive
items.value.pop() // ✓ Reactive
items.value.splice(1, 1, 'x') // ✓ Reactive
items.value.sort() // ✓ Reactive
// Array replacements (always safe):
items.value = [...items.value, 'd'] // ✓ Reactive — new array reference
state.tags = [...state.tags, 'ts'] // ✓ Reactive — new array in reactive object
// Index assignment on reactive arrays:
state.tags[0] = 'vue3' // ✓ Reactive in Vue 3 (Proxy-based)
// Note: this was NOT reactive in Vue 2 — Vue 3 fixed this with Proxy
// Map and Set in ref — fully reactive in Vue 3
const map = ref(new Map<string, number>())
map.value.set('key', 1) // ✓ Reactive — Proxy wraps Map operations
map.value.delete('key') // ✓ Reactive
const set = ref(new Set<string>())
set.value.add('item') // ✓ Reactive
</script>Fix 7: Debug Reactivity Issues
When the template isn’t updating despite state changes:
<script setup>
import { ref, reactive, watchEffect } from 'vue'
const state = reactive({ count: 0 })
// watchEffect to diagnose what Vue tracks
watchEffect(() => {
console.log('[reactivity debug] count:', state.count)
// If this doesn't log when count changes, Vue isn't tracking count
})
// Check if a value is reactive
import { isRef, isReactive, isProxy } from 'vue'
const count = ref(0)
const obj = reactive({ x: 1 })
const plain = { y: 2 }
console.log(isRef(count)) // true
console.log(isReactive(obj)) // true
console.log(isReactive(plain)) // false — plain object, not reactive
// Check what you get after destructuring
const { x } = obj
console.log(isRef(x)) // false — plain number after destructuring
const { x: xRef } = toRefs(obj)
console.log(isRef(xRef)) // true — now a ref
</script>Vue DevTools — the Vue DevTools browser extension shows the reactive state of each component in real time. If a value shows correctly in DevTools but the template doesn’t update, the issue is likely a template binding problem, not reactivity.
Still Not Working?
shallowRef and shallowReactive — the shallow variants only track top-level reactivity. Nested property changes don’t trigger updates. Use them only for performance-critical large objects.
Returning from composables — when a composable returns reactive state, return refs (or use toRefs) so callers can destructure:
// composables/useCounter.js
export function useCounter() {
const state = reactive({ count: 0, step: 1 })
// WRONG — caller's destructure loses reactivity
// return state
// CORRECT — spread with toRefs
return {
...toRefs(state),
increment: () => state.count += state.step,
}
}triggerRef — for manually forcing a ref update (e.g., after mutating a raw object stored in a ref that Vue can’t detect):
import { ref, triggerRef } from 'vue'
const user = ref({ name: 'Alice' })
user.value.name = 'Bob' // Vue usually detects this via Proxy
triggerRef(user) // Force update in edge cases (e.g., shallowRef)Async update timing — Vue batches DOM updates. After mutating state, the template hasn’t updated yet on the same tick. Use await nextTick() if you need to read the post-update DOM:
import { nextTick } from 'vue'
state.count = 5
// DOM still shows old count here
await nextTick()
// Now the DOM reflects state.count = 5Composable returns the wrong shape — if your composable returns the reactive object directly (return state) instead of return toRefs(state), every consumer that destructures loses reactivity. Returning a fresh toRefs(state) plus action functions is the safer convention. Spreading actions outside the toRefs call also avoids accidentally wrapping plain functions into refs.
markRaw on a reactive object — markRaw permanently opts an object out of reactivity. If you accidentally call markRaw on data that needs to react (often happens when you pass third-party class instances through a reactive store), no updates will fire. Audit markRaw usage and remove it for anything the UI depends on.
v-model on a destructured ref — binding v-model to a destructured variable that’s no longer a ref silently breaks two-way binding. The input updates the local variable but never propagates back. Always pass the original ref (or a toRefs result) into the v-model target.
For related Vue issues, see Fix: Vue Computed Not Updating, Fix: Vue Reactive Data Not Updating, Fix: Vue Composable Not Reactive, and Fix: Vue Pinia State Not Reactive.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Nuxt Not Working — useFetch Returns Undefined, Server Route 404, or Hydration Mismatch
How to fix Nuxt 3 issues — useFetch vs $fetch, server routes in server/api, composable SSR rules, useAsyncData, hydration errors, and Nitro configuration problems.
Fix: Vue Router Params Not Updating — Component Not Re-rendering or beforeRouteUpdate Not Firing
How to fix Vue Router params not updating when navigating between same-route paths — watch $route, beforeRouteUpdate, onBeforeRouteUpdate, and component reuse behavior explained.
Fix: Vue Slot Not Working — Named Slots Not Rendering or Scoped Slot Data Not Accessible
How to fix Vue 3 slot issues — v-slot syntax, named slots, scoped slots passing data, default slot content, fallback content, and dynamic slot names.
Fix: Vue Teleport Not Rendering — Content Not Appearing at Target Element
How to fix Vue Teleport not working — target element not found, SSR with Teleport, disabled prop, multiple Teleports to the same target, and timing issues.