Skip to content

Fix: Vue Computed Property Not Updating — Reactivity Not Triggered

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix Vue computed properties not updating — reactive dependency tracking, accessing nested objects, computed setters, watchEffect vs computed, and Vue 3 reactivity pitfalls.

The Problem

A Vue computed property doesn’t update when its dependencies change:

<script setup>
import { ref, computed } from 'vue';

const user = ref({ name: 'Alice', address: { city: 'Tokyo' } });

const displayName = computed(() => user.value.name);

// Later in code:
user.value = { ...user.value, name: 'Bob' };  // ← Why isn't displayName updating?
</script>

Or a computed property based on a reactive object doesn’t react to property changes:

const state = reactive({ items: ['a', 'b', 'c'] });

const itemCount = computed(() => state.items.length);

state.items.push('d');  // itemCount should update to 4
// But if items was replaced instead: state.items = [...state.items, 'd']
// itemCount may not update depending on how reactivity is broken

Or in Vue 2, adding a new property to an object doesn’t trigger computed updates:

// Vue 2
export default {
  data() {
    return { user: { name: 'Alice' } };
  },
  computed: {
    greeting() {
      return `Hello, ${this.user.nickname}`;  // nickname added later — not reactive
    },
  },
  mounted() {
    this.user.nickname = 'Ali';  // NOT reactive in Vue 2 — computed doesn't update
  },
};

Why This Happens

Vue’s reactivity system tracks dependencies by recording which reactive properties are accessed during a computed property’s execution. The computed value is recalculated only when those tracked dependencies change.

This tracking is automatic but not magical. Vue instruments reactive objects with getters and setters (Vue 2) or Proxies (Vue 3). When a computed getter runs, Vue records every reactive property the getter touches. The next time any of those properties changes, Vue marks the computed as “dirty” and re-evaluates it on the next access. If a property is never accessed during the getter’s execution — because it’s behind a short-circuit condition, or because the code destructured a reactive object into plain values before the getter ran — Vue has no record of it and can’t trigger an update.

The most common source of confusion is the difference between reactive references and plain JavaScript values. A ref is a wrapper object with a .value property. When you destructure a reactive object or extract a ref’s value into a local variable, you get a plain JavaScript primitive or object — not a reactive reference. Changes to the original reactive source won’t propagate to that extracted copy.

Reactivity breaks when:

  • Replacing a ref object entirely without updating .value — direct assignment to a ref without going through .value loses reactivity.
  • Adding new properties to a reactive() object in Vue 2 — Vue 2’s reactivity can’t detect property additions. Vue 3 (using Proxy) doesn’t have this limitation.
  • Destructuring reactive objects — destructuring a reactive() object breaks reactivity because you get primitive copies, not reactive references.
  • Accessing computed outside the reactive tree — reading computed values in non-reactive contexts (event handlers, lifecycle hooks that run after component unmount) doesn’t track dependencies.
  • Mutating an array with non-reactive methods in Vue 2 — in Vue 2, only specific array mutation methods (push, pop, splice, etc.) are reactive. Direct index assignment (arr[0] = value) is not.
  • Lazy computed with stale closure — computed properties memoize results. If the dependency tracking misses a reactive source (because it’s not accessed during the first evaluation), subsequent changes don’t trigger updates.

Diagnostic Timeline

The first instinct when a computed property doesn’t update is to replace it with a watch. That works around the symptom but misses the root cause — computed properties should react automatically, and if they don’t, a reactive dependency is broken. Walk through these steps instead.

Minute 0 — Check if the source is actually reactive. Log the value with isRef(myValue) or isReactive(myValue) from Vue’s API. If neither returns true, the value is a plain JavaScript variable and Vue can’t track it. This happens when you import a non-reactive constant, destructure a reactive object, or create a plain object and forget to wrap it in ref() or reactive().

Minute 2 — Check for non-reactive property access. Open Vue DevTools, select the component, and find the computed property. Click it to see its tracked dependencies. If a dependency you expect is missing, the computed getter isn’t accessing it during evaluation. This can happen with short-circuit logic: computed(() => condition && state.value) doesn’t track state.value if condition is false on the first evaluation.

Minute 4 — Verify the template uses .value. In <script setup>, refs require .value in JavaScript but are auto-unwrapped in templates. However, inside nested structures (a ref inside a reactive object), auto-unwrapping doesn’t always work. If you pass a ref through a composable or store, make sure the consuming code accesses .value explicitly in script and that the template accesses the property directly.

Minute 6 — Check for array mutation without triggering reactivity. If the computed depends on an array, verify how the array is modified. In Vue 3, push, splice, and direct index assignment all trigger reactivity on reactive arrays. But if the array was extracted from a reactive object into a local variable (const items = state.items), mutating the local variable may not trigger the computed because the local reference is no longer tracked.

Minute 8 — Check for Vue 2 property addition. In Vue 2, adding a new property to an object with this.obj.newProp = value is not reactive. Use Vue.set(this.obj, 'newProp', value) or this.$set(this.obj, 'newProp', value). In Vue 3, this limitation does not exist — Proxy-based reactivity tracks property additions automatically.

Minute 10 — Test with watchEffect. Replace the computed temporarily with a watchEffect that logs the same expression. If watchEffect triggers but the computed doesn’t, the computed may be cached and not re-accessed after the dependency changes (e.g., the component’s template doesn’t use the computed value, so Vue doesn’t bother re-evaluating it).

Fix 1: Always Access and Modify ref Values Through .value

<script setup>
import { ref, computed } from 'vue';

const user = ref({ name: 'Alice' });
const displayName = computed(() => user.value.name);

// WRONG — replacing the ref object entirely (loses reactivity tracking)
// user = { name: 'Bob' };   ← Can't reassign const ref anyway

// WRONG in Vue 3 — doesn't trigger computed update
// Object.assign(user, { name: 'Bob' });  ← 'user' is the ref wrapper, not the value

// CORRECT — modify through .value
user.value = { ...user.value, name: 'Bob' };  // Replace entire object
// OR
user.value.name = 'Bob';  // Mutate nested property (Vue 3 tracks this)

// Verify with a watcher
watch(displayName, (newVal) => {
  console.log('displayName changed to:', newVal);
});
</script>

Deep reactivity with ref in Vue 3:

<script setup>
import { ref, computed } from 'vue';

const state = ref({
  user: {
    profile: {
      name: 'Alice',
      city: 'Tokyo',
    },
  },
});

// Vue 3's ref() makes nested objects deeply reactive
const cityName = computed(() => state.value.user.profile.city);

// This triggers cityName to update — Vue 3 tracks deep property access
state.value.user.profile.city = 'Osaka';
</script>

Fix 2: Don’t Destructure reactive() Objects

Destructuring a reactive() object extracts primitive values — these are copies, not reactive:

<script setup>
import { reactive, computed, toRef, toRefs } from 'vue';

const state = reactive({ count: 0, name: 'Alice' });

// WRONG — destructuring breaks reactivity
const { count, name } = state;
const doubled = computed(() => count * 2);  // count is a plain number — not reactive
// doubled never updates even when state.count changes

// CORRECT option 1 — access state directly in computed
const doubled = computed(() => state.count * 2);  // state.count is reactive

// CORRECT option 2 — use toRefs() to convert to reactive refs
const { count, name } = toRefs(state);
// count is now a Ref<number> — reactive
const doubled = computed(() => count.value * 2);  // Updates when state.count changes

// CORRECT option 3 — use toRef() for a single property
const countRef = toRef(state, 'count');
const doubled = computed(() => countRef.value * 2);
</script>

In composables — always return toRefs() to preserve reactivity for destructuring callers:

// composables/useUser.ts
export function useUser() {
  const state = reactive({
    name: '',
    email: '',
    isLoading: false,
  });

  // WRONG — caller loses reactivity when destructuring
  // return state;

  // CORRECT — return reactive refs so caller can destructure
  return {
    ...toRefs(state),
    // methods don't need toRef
    async fetchUser(id: number) {
      state.isLoading = true;
      const user = await api.getUser(id);
      state.name = user.name;
      state.email = user.email;
      state.isLoading = false;
    },
  };
}

// Caller can safely destructure
const { name, email, isLoading, fetchUser } = useUser();
// name, email, isLoading are Refs — remain reactive

Fix 3: Fix Array Reactivity Issues

In Vue 3, arrays inside reactive() and ref() are deeply reactive — most mutations trigger updates. But there are still pitfalls:

<script setup>
import { reactive, computed } from 'vue';

const state = reactive({ items: ['a', 'b', 'c'] });
const itemCount = computed(() => state.items.length);

// All of these trigger computed updates in Vue 3:
state.items.push('d');          // ✓ Mutation — reactive
state.items.splice(1, 1);       // ✓ Mutation — reactive
state.items = [...state.items, 'e'];  // ✓ Replace — reactive (state is reactive obj)

// WRONG — replacing with a non-reactive array
// const items = state.items;
// items.push('f');  // Mutating a captured reference — may not trigger update
</script>

When filtering/mapping, return a new array to the reactive state:

<script setup>
const state = reactive({
  todos: [
    { id: 1, text: 'Buy milk', done: false },
    { id: 2, text: 'Write code', done: true },
  ],
});

const incompleteTodos = computed(() =>
  state.todos.filter(todo => !todo.done)
);

// Update a todo's done status — triggers computed update
function toggleTodo(id: number) {
  const todo = state.todos.find(t => t.id === id);
  if (todo) {
    todo.done = !todo.done;  // Reactive — Vue 3 tracks nested mutations
  }
}
</script>

Vue 2 array reactivity limitations:

// Vue 2 — only these array methods are reactive:
// push, pop, shift, unshift, splice, sort, reverse

// WRONG in Vue 2 — direct index assignment NOT reactive
this.items[0] = 'new value';  // Doesn't trigger update

// CORRECT in Vue 2
this.$set(this.items, 0, 'new value');
// or
this.items.splice(0, 1, 'new value');

Fix 4: Use computed with Getters and Setters

Computed properties are read-only by default. To make them writable, provide a setter:

<script setup>
import { ref, computed } from 'vue';

const firstName = ref('Alice');
const lastName = ref('Smith');

// Read-only computed
const fullName = computed(() => `${firstName.value} ${lastName.value}`);

// WRONG — can't assign to a read-only computed
// fullName.value = 'Bob Jones';  // TypeError: computed value is readonly

// Read-write computed with getter and setter
const fullName = computed({
  get() {
    return `${firstName.value} ${lastName.value}`;
  },
  set(newValue: string) {
    [firstName.value, lastName.value] = newValue.split(' ');
  },
});

// Now you can both read and write
console.log(fullName.value);         // 'Alice Smith'
fullName.value = 'Bob Jones';        // Triggers the setter
console.log(firstName.value);        // 'Bob'
console.log(lastName.value);         // 'Jones'
</script>

Computed setter for v-model on computed values:

<template>
  <!-- Works because fullName has a setter -->
  <input v-model="fullName" />
</template>

<script setup>
const fullName = computed({
  get: () => `${firstName.value} ${lastName.value}`,
  set: (val) => {
    const [first, ...rest] = val.split(' ');
    firstName.value = first;
    lastName.value = rest.join(' ');
  },
});
</script>

Fix 5: Know When to Use watch vs computed

Computed properties are for derived data. watch and watchEffect are for side effects:

<script setup>
import { ref, computed, watch, watchEffect } from 'vue';

const searchQuery = ref('');
const results = ref([]);

// WRONG — computed for async work (can't use await in computed)
const searchResults = computed(async () => {
  const data = await fetch(`/api/search?q=${searchQuery.value}`);
  return data.json();
  // This returns a Promise — not the actual data
});

// CORRECT — use watch for async side effects
watch(searchQuery, async (query) => {
  if (!query) {
    results.value = [];
    return;
  }
  const data = await fetch(`/api/search?q=${query}`);
  results.value = await data.json();
});

// OR use watchEffect — automatically tracks dependencies
watchEffect(async () => {
  const query = searchQuery.value;  // Tracked dependency
  if (!query) return;
  const data = await fetch(`/api/search?q=${query}`);
  results.value = await data.json();
});
</script>

Choose between computed and watch:

Use caseUse
Derive data from reactive statecomputed
Run async operations on changewatch / watchEffect
Complex logic with multiple dependenciescomputed
Trigger side effects (API calls, DOM)watch
Need old and new valueswatch
Immediate execution on setupwatchEffect or watch with { immediate: true }

Fix 6: Debug Reactivity Issues

Vue DevTools shows the computed property’s dependencies and current value — the fastest way to diagnose why it’s not updating:

  1. Install Vue DevTools (browser extension).
  2. Select the component in the DevTools panel.
  3. Find the computed property.
  4. Click it to see tracked dependencies — the reactive properties it reads.
  5. If a dependency you expect to see is missing, Vue isn’t tracking it.

Manual debugging with watchEffect:

<script setup>
import { watchEffect } from 'vue';

// watchEffect runs immediately and logs all tracked dependencies
watchEffect(() => {
  console.log('Tracked: user.name =', user.value.name);
  console.log('Tracked: settings.theme =', settings.theme);
  // This tells you exactly what Vue is tracking
});
</script>

Check if a value is reactive:

import { isRef, isReactive, isReadonly } from 'vue';

console.log(isRef(myValue));       // true if it's a ref
console.log(isReactive(myValue));  // true if it's reactive
console.log(isReadonly(myValue));  // true if it's readonly (e.g., computed)

Still Not Working?

Computed value returns a Promise — if the getter function is async, the computed property’s value is a Promise, not the resolved data. Use watch for async operations (see Fix 5).

Stale computed after component prop change — if a computed property depends on a prop and the parent changes the prop, the computed should update automatically. If it doesn’t, verify the prop is declared correctly:

<script setup>
// Props are reactive — computed works correctly
const props = defineProps<{ userId: number }>();

const userEndpoint = computed(() => `/api/users/${props.userId}`);
// Updates when userId prop changes ✓
</script>

Pinia store values in computed — accessing Pinia store state in computed works with the Composition API:

<script setup>
import { computed } from 'vue';
import { useUserStore } from '@/stores/user';

const store = useUserStore();

// CORRECT — access store properties directly (they're reactive)
const displayName = computed(() => store.user?.name ?? 'Guest');

// WRONG — destructuring breaks reactivity (same as reactive())
// const { user } = store;  ← user is no longer reactive
</script>

shallowRef and shallowReactive skip deep tracking — if the source was created with shallowRef() or shallowReactive(), only top-level property changes trigger reactivity. Nested object mutations won’t trigger the computed. Either switch to ref() / reactive() for deep tracking, or use triggerRef(myShallowRef) to manually notify Vue after a nested mutation.

Computed in a detached scope — if you create a computed property inside a function that runs outside a component’s setup() (e.g., in a setTimeout callback or a standalone script), the computed has no component scope and may be garbage collected. Use effectScope() to create a scope that keeps the computed alive:

import { effectScope, computed, ref } from 'vue';

const scope = effectScope();
const count = ref(0);

scope.run(() => {
  const doubled = computed(() => count.value * 2);
  // This computed stays alive as long as the scope is active
});

// Clean up when done
scope.stop();

Computed not updating in Vue 2 with Vuex — if the computed reads from a Vuex getter, make sure the Vuex state mutation uses Vue.set() for new properties and reactive array methods for arrays. Vuex state follows the same Vue 2 reactivity rules as component data.

For related Vue issues, see Fix: Vue Reactive Data Not Updating, Fix: Vue Router Navigation Guard Not Working, Fix: Vue Pinia State Not Reactive, and Fix: Vue Composable Not Reactive.

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