Fix: Vue 3 Reactive Data Not Updating (ref/reactive Not Triggering Re-render)
Quick Answer
How to fix Vue 3 reactive data not updating the UI — why ref and reactive lose reactivity, how to correctly mutate reactive state, and common pitfalls with destructuring and nested objects.
The Error
You update a reactive variable in Vue 3 but the component does not re-render, or the template shows stale data:
import { reactive } from 'vue';
const state = reactive({ count: 0, user: null });
function updateUser() {
// This completely replaces the reactive object — breaks reactivity
state = { count: 1, user: { name: 'Alice' } };
}Or with ref:
import { ref } from 'vue';
const items = ref([1, 2, 3]);
function addItem() {
items = ref([...items.value, 4]); // Reassigning ref — loses reactivity
}Or data updates correctly in the console but the template never refreshes:
const state = reactive({ user: { address: { city: 'Tokyo' } } });
// Template shows old city — nested mutation not always reactive
state.user.address = { city: 'Osaka' }; // This works
state.user = null; // Then this — template may not update as expectedWhy This Happens
Vue 3’s reactivity system tracks dependencies at the property level using JavaScript Proxies. Several patterns break this tracking:
- Reassigning a
reactive()object — replaces the Proxy with a plain object. Vue can no longer track changes because the original Proxy reference is gone. - Reassigning a
ref—refwraps a value; reassigning the variable holding the ref replaces the ref itself, not its.value. - Destructuring a
reactiveobject — extracts primitive values directly, severing the reactive connection. - Replacing nested objects entirely — while Vue 3 handles nested object mutations well, replacing an entire nested object can sometimes bypass tracking in edge cases.
- Mutating arrays incorrectly — direct index assignment (
arr[0] = value) works in Vue 3 (unlike Vue 2), but replacing the array reference does not.
Fix 1: Never Reassign a reactive() Object — Mutate Its Properties
reactive() returns a Proxy. If you reassign the variable, you lose the Proxy:
Broken — reassigning replaces the Proxy:
import { reactive } from 'vue';
let state = reactive({ count: 0 });
function reset() {
state = reactive({ count: 0 }); // Creates new Proxy — template still watches old one
}Fixed — mutate properties on the existing object:
import { reactive } from 'vue';
const state = reactive({ count: 0, name: '', items: [] });
function reset() {
state.count = 0; // ✓ Mutating property — reactive
state.name = ''; // ✓
state.items = []; // ✓
}
// Or use Object.assign to bulk-reset:
function resetAll() {
Object.assign(state, { count: 0, name: '', items: [] }); // ✓
}Why this works:
Object.assigncopies properties onto the existing Proxy object, so Vue’s dependency tracking continues to work. The Proxy itself is never replaced.
Fix 2: Always Access ref Values via .value
ref wraps its value in an object with a .value property. The reactive tracking happens on .value, not on the variable holding the ref:
Broken — reassigning the ref variable:
import { ref } from 'vue';
let count = ref(0);
let items = ref([]);
function update() {
count = ref(1); // ✗ Replaces the ref — template loses connection
items = ref([1, 2, 3]); // ✗ Same issue
}Fixed — assign to .value:
import { ref } from 'vue';
const count = ref(0);
const items = ref([]);
function update() {
count.value = 1; // ✓
items.value = [1, 2, 3]; // ✓
// For arrays, you can also mutate in-place:
items.value.push(4); // ✓
items.value.splice(0, 1); // ✓
}In templates, .value is automatically unwrapped:
<template>
<!-- No .value needed in the template -->
<p>{{ count }}</p>
<p>{{ items.length }}</p>
</template>
<script setup>
import { ref } from 'vue';
const count = ref(0);
const items = ref([]);
</script>Common Mistake: Forgetting
.valueinside<script setup>or<script>blocks. The auto-unwrapping only happens in the template — in JavaScript code you always need.value.
Fix 3: Do Not Destructure reactive() Objects
Destructuring a reactive object extracts the current value of each property as a plain (non-reactive) variable:
Broken — destructuring breaks reactivity:
import { reactive } from 'vue';
const state = reactive({ count: 0, name: 'Alice' });
// Destructuring extracts current values — they are no longer reactive
const { count, name } = state;
function increment() {
count++; // This updates the local variable, not state.count
// Template does not re-render
}Fixed — use toRefs() to destructure reactively:
import { reactive, toRefs } from 'vue';
const state = reactive({ count: 0, name: 'Alice' });
// toRefs converts each property to a ref — maintains reactivity
const { count, name } = toRefs(state);
function increment() {
count.value++; // ✓ Updates state.count — template re-renders
}Or access state properties directly:
// Skip destructuring — just use state.count
function increment() {
state.count++; // ✓ Always reactive
}In <script setup>, use toRefs for clean template access:
<script setup>
import { reactive, toRefs } from 'vue';
const state = reactive({ count: 0, name: 'Alice' });
const { count, name } = toRefs(state); // Each is a ref now
</script>
<template>
<p>{{ count }}</p> <!-- count.value auto-unwrapped -->
<p>{{ name }}</p>
</template>Fix 4: Fix Reactivity Lost in Composables
A common pattern in Vue 3 composables is returning reactive state — destructuring that return value loses reactivity:
Broken — composable returns reactive, caller destructures:
// useCounter.js
import { reactive } from 'vue';
export function useCounter() {
const state = reactive({ count: 0 });
function increment() {
state.count++;
}
return { state, increment }; // Fine if returned as-is
// But if you return spread: return { ...state, increment } — BROKEN
}
// Component.vue
import { useCounter } from './useCounter';
const { count, increment } = useCounter(); // If useCounter spreads state, count is not reactiveFixed — return refs or use toRefs in composables:
// useCounter.js — Option A: return refs
import { ref } from 'vue';
export function useCounter() {
const count = ref(0);
function increment() {
count.value++;
}
return { count, increment }; // refs are safe to destructure
}
// useCounter.js — Option B: return toRefs of reactive
import { reactive, toRefs } from 'vue';
export function useCounter() {
const state = reactive({ count: 0 });
function increment() {
state.count++;
}
return { ...toRefs(state), increment }; // ✓ Spread toRefs — refs survive destructuring
}
// Component.vue — works with both options
import { useCounter } from './useCounter';
const { count, increment } = useCounter();
// count.value in script, {{ count }} in templateFix 5: Fix Array Reactivity Issues
Vue 3 handles most array mutations reactively, but replacing the array reference requires .value:
import { ref, reactive } from 'vue';
// With ref
const list = ref([1, 2, 3]);
// All of these work:
list.value.push(4); // ✓ Mutation in-place
list.value.splice(1, 1); // ✓
list.value[0] = 99; // ✓ Index assignment works in Vue 3
list.value = [...list.value, 4]; // ✓ Replace .value
// With reactive
const state = reactive({ list: [1, 2, 3] });
state.list.push(4); // ✓
state.list[0] = 99; // ✓
state.list = [...state.list, 4]; // ✓ Replace propertyFiltering or sorting without losing reactivity:
const items = ref([
{ id: 1, name: 'Alpha', active: true },
{ id: 2, name: 'Beta', active: false },
]);
function filterActive() {
items.value = items.value.filter(item => item.active); // ✓
}
function sortByName() {
items.value = [...items.value].sort((a, b) => a.name.localeCompare(b.name)); // ✓
}Fix 6: Use watchEffect or watch to Debug Reactivity
If you cannot tell whether a value is reactive, use watchEffect to confirm Vue is tracking it:
import { ref, watchEffect, watch } from 'vue';
const count = ref(0);
// watchEffect runs immediately and re-runs when any reactive dependency changes
watchEffect(() => {
console.log('count changed:', count.value);
// If this never logs after your update, the value is not reactive
});
// watch a specific source
watch(count, (newVal, oldVal) => {
console.log(`count: ${oldVal} → ${newVal}`);
});
// Watch a reactive object property
const state = reactive({ user: null });
watch(() => state.user, (newUser) => {
console.log('user changed:', newUser);
});Check reactivity with Vue DevTools:
Install the Vue DevTools browser extension. It shows the reactive state of every component in real time — if a value updates in the component tree but the template does not reflect it, it is a template binding issue, not a reactivity issue.
Fix 7: Fix Reactivity with Async Data
Reactive state set asynchronously sometimes causes issues if the variable was reassigned rather than mutated:
Broken — reassigning inside async:
import { reactive } from 'vue';
const state = reactive({ users: [], loading: false });
async function fetchUsers() {
state.loading = true;
const data = await api.getUsers();
state = { ...state, users: data, loading: false }; // ✗ Replaces Proxy
}Fixed — mutate properties:
async function fetchUsers() {
state.loading = true;
try {
const data = await api.getUsers();
state.users = data; // ✓
state.loading = false; // ✓
} catch (error) {
state.loading = false;
state.error = error.message;
}
}Using ref for async data:
import { ref } from 'vue';
const users = ref([]);
const loading = ref(false);
async function fetchUsers() {
loading.value = true;
try {
users.value = await api.getUsers(); // ✓
} finally {
loading.value = false;
}
}Still Not Working?
Check if you are outside the Vue reactivity context. Reactive state only triggers re-renders when accessed in a reactive context (template, computed, watchEffect, watch). If you read state.count in a plain function that is not tracked, Vue will not re-run it.
Check for shallowRef or shallowReactive. These only track the top-level reference, not deep property changes. If you used shallowRef, you must replace .value entirely — mutating nested properties will not trigger updates.
import { shallowRef } from 'vue';
const state = shallowRef({ nested: { count: 0 } });
// ✗ Does not trigger update — shallowRef only watches the top-level reference
state.value.nested.count++;
// ✓ Replace .value entirely to trigger update
state.value = { nested: { count: state.value.nested.count + 1 } };Check computed property dependencies. If a computed value does not update, verify it actually accesses the reactive state inside the getter — any dependency accessed outside the getter function is not tracked.
import { ref, computed } from 'vue';
const count = ref(0);
const double = computed(() => count.value * 2); // ✓ count.value accessed inside getter
// Broken pattern:
let multiplier = 3; // plain variable — not reactive
const result = computed(() => count.value * multiplier); // multiplier changes won't update result
// Fix: make multiplier a ref
const multiplier = ref(3);
const result = computed(() => count.value * multiplier.value); // ✓For related Vue issues, see Fix: Vue Router navigation not working and Fix: JavaScript Closure Loop Bug.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: CSS Custom Properties (Variables) Not Working or Not Updating
How to fix CSS custom properties not applying — wrong scope, missing fallback values, JavaScript not setting variables on the right element, and how CSS variables interact with media queries and Shadow DOM.
Fix: Next.js Middleware Not Running (middleware.ts Not Intercepting Requests)
How to fix Next.js middleware not executing — wrong file location, matcher config errors, middleware not intercepting API routes, and how to debug middleware execution in Next.js 13 and 14.
Fix: Next.js Build Failed (next build Errors and How to Fix Them)
How to fix Next.js build failures — TypeScript errors blocking production builds, module resolution failures, missing environment variables, static generation errors, and common next build crash causes.
Fix: Vite Build Chunk Size Warning (Some Chunks Are Larger Than 500 kB)
How to fix Vite's chunk size warning — why bundles exceed 500 kB, how to split code with dynamic imports and manualChunks, configure the chunk size limit, and optimize your Vite production build.