Skip to content

Fix: Vue Composition API Reactivity Lost — Destructured Props or Reactive Object Not Updating

FixDevs ·

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 })count is now a plain number, not a reactive reference.
  • ref() objects are different — ref(0) wraps the value in a { value } object. You access the value via .value. Destructuring a ref itself is fine (the ref stays reactive).
  • Propsprops from defineProps is a reactive object. Destructuring it with const { name } = props loses 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 stores

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

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles