Skip to content

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

FixDevs · (Updated: )

Part of:  React & Frontend Errors

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. Each reactive() call wraps an object in a Proxy that intercepts get and set operations. The template’s render function builds a dependency list during its first run by reading those Proxy properties — each property access registers the render function as a subscriber. When a tracked property is written, Vue marks the render function dirty and schedules a re-render on the next microtask tick.

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. Primitives are passed by value in JavaScript, so the destructured variable holds a snapshot, not a live reference.
  • 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.
  • Reading outside a reactive contextstate.count accessed in a plain callback (DOM event handler outside <script setup>, a setTimeout body, a third-party library callback) reads the value but never registers as a dependency. The render function never gets notified.
  • Mixing markRaw or Object.freeze with reactive state — both opt out of reactivity. Frozen objects cannot be wrapped by Proxies and produce silent no-ops on write.

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;
  }
}

In Production: Incident Lens

A reactivity bug rarely throws a stack trace. The application keeps rendering — it just shows stale data. That makes it one of the harder Vue regressions to catch in production.

Surface. Users report “the page shows old data until I hit refresh,” or a counter that should tick up sits frozen, or a form submit succeeds but the list does not append the new row. Sentry stays silent because nothing throws. The first signal is usually a support ticket or a session replay where the user clicks the same button three times.

Blast radius. The defect is per-component instance. If the broken reactivity sits inside a list item component, only users who interact with that row see staleness. If it sits inside the root layout (a global store binding, a layout-level reactive() that gets reassigned on route change), every page after the trigger renders stale. The data-layer Pinia or Vuex store usually stays correct — the symptom is purely at the render boundary.

Alerting. Real User Monitoring is the only practical signal. Watch for elevated “time-to-interactive but click count unchanged” patterns — users clicking the same button repeatedly. Sentry replay sessions tagged with “frustration: dead click” surface the same pattern. Funnel analytics catch the downstream effect: form submissions that succeed server-side but show no follow-up event from the same session.

Recovery. There is no live mitigation other than asking the user to reload. A page-level force-refresh button gated by a feature flag is a useful pressure-release valve when you ship a quick fix. The real fix is forward-only — restructure the component to mutate properties on the existing Proxy or convert raw reactive() to ref() for state that gets fully replaced. Deploy a patched bundle, invalidate the SW cache so service-worker-served stale code is not the actual culprit, and verify with a session replay.

Preventive. Cover the broken paths with end-to-end tests (Playwright, Cypress) that exercise the mutation and then assert the rendered DOM, not just the store state. Add Vue DevTools timeline snapshots to your CI flake-detection layer. Adopt the Pinia store pattern for any state that crosses component boundaries — Pinia’s $patch API guarantees the proxy is preserved. Lint rules from eslint-plugin-vue (vue/no-ref-as-operand, vue/no-mutating-props) catch a subset of these mistakes at PR review time. For composables, treat the return contract as a public API: always toRefs(state) or expose refs directly, never spread a reactive() object.

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); // ✓

Check if you are mutating a prop. Props are read-only by design. Mutating a prop from a child component triggers a Vue warning in development but silently fails in production. Emit an event to the parent instead, or use v-model with a backing local ref.

Check for an accidentally-frozen object. Calling Object.freeze() on an object before passing it to reactive() makes the Proxy unable to intercept writes. Vue 3 detects this and falls back to read-only mode without warning in production builds.

Check for two separate Vue instances loading on the same page. If a micro-frontend or a vendored library imports its own copy of Vue, components from one instance cannot observe state from the other. Run npm ls vue and ensure there is exactly one copy in the dependency tree.

For related Vue issues, see Fix: Vue Router navigation not working, Fix: JavaScript Closure Loop Bug, Fix: Vue Composable Not Reactive, and Fix: Vue Computed Not Updating.

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