Skip to content

Fix: Vue 3 Reactive Data Not Updating (ref/reactive Not Triggering Re-render)

FixDevs ·

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 expected

Why 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 refref wraps a value; reassigning the variable holding the ref replaces the ref itself, not its .value.
  • Destructuring a reactive object — 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.assign copies 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 .value inside <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 reactive

Fixed — 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 template

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

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

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