Fix: Vue Composition API Reactivity Lost — Destructured Props or Reactive Object Not Updating
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 property reads and writes. When you destructure a reactive object, you extract the current values as plain JavaScript values — the Proxy link is severed.
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. - Pinia stores — state properties on store objects are reactive when accessed via
store.property, but not after plain destructuring.
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)For related Vue issues, see Fix: Vue Computed Not Updating and Fix: Pinia Reactivity Not Working.
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.