Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 006 / 100

Vue reactivity explained: a hands-on guide for engineers

How Vue tracks changes.

Expona Admin8 min read

Introduction

Vue's reactivity system is one of its most powerful features, yet it remains a source of confusion for engineers moving from other frameworks or hitting edge cases in production. Understanding Vue reactivity explained: a hands-on guide for engineers means grasping how Vue intercepts property access and mutations—and knowing when that magic stops working. In this post, we'll walk through the mechanisms Vue uses to track changes, explore the practical differences between ref and reactive, and show you the pitfalls that catch teams in real applications.

How Proxies work: Vue's change-tracking foundation

At the core of Vue 3's reactivity sits the JavaScript Proxy object. When you create a reactive object, Vue wraps it in a Proxy that intercepts all property reads and writes. This interception happens transparently—your code looks normal, but Vue is quietly tracking what you access and change.

Here's a simplified mental model of what Vue does behind the scenes:

typescript
// Vue's reactivity is roughly like this
const target = { count: 0 };
const handler = {
  get(target, key) {
    // Track that this property was read
    console.log(`Reading ${key}`);
    return target[key];
  },
  set(target, key, value) {
    // Track that this property changed
    console.log(`Setting ${key} to ${value}`);
    target[key] = value;
    // Trigger re-renders
    return true;
  }
};
const reactive = new Proxy(target, handler);

Vue's actual implementation is far more sophisticated, tracking dependencies at a granular level and batching updates for performance. But the core idea is the same: Proxies let Vue see every read and write.

In a real component, this means Vue can automatically know which parts of state are used in your template or computed properties, and which mutations trigger re-renders. You don't have to manually declare dependencies—Vue figures it out by observing what you touch.

typescript
import { reactive } from 'vue';

export default {
  setup() {
    const state = reactive({
      user: { name: 'Alice', age: 30 },
      loading: false
    });

    // When template renders {{ state.user.name }}, Vue tracks this access
    // When you mutate state.user.name, Vue knows to re-render
    const updateName = (newName) => {
      state.user.name = newName;
    };

    return { state, updateName };
  }
};

One critical limitation: Proxies cannot intercept new properties added after the object is created. If you do state.newField = 'value' on an object that didn't have newField initially, Vue won't track it. This is why you must define all reactive properties upfront, or use methods like Object.assign to add them safely.

ref vs reactive: choosing the right tool

Vue gives you two main ways to create reactive values: ref and reactive. They solve the same problem differently, and picking the right one matters for code clarity and performance.

Use reactive for objects you mutate in place:

typescript
import { reactive } from 'vue';

export default {
  setup() {
    const form = reactive({
      email: '',
      password: '',
      remember: false
    });

    const submitForm = () => {
      // Mutate properties directly
      form.email = '';
      form.password = '';
    };

    return { form, submitForm };
  }
};

Use ref for primitives or when you replace the entire value:

typescript
import { ref } from 'vue';

export default {
  setup() {
    const count = ref(0);
    const user = ref({ name: 'Alice' });

    const increment = () => {
      // With ref, access via .value
      count.value++;
    };

    const replaceUser = (newUser) => {
      // Replacing the entire object? ref is cleaner
      user.value = newUser;
    };

    return { count, user, increment, replaceUser };
  }
};

The key difference: ref wraps your value in an object with a .value property, so it works with primitives and can be replaced entirely. reactive only works with objects and tracks mutations, not replacements. In templates, Vue automatically unwraps ref, so you can use {{ count }} instead of {{ count.value }}.

In composables and reusable logic, ref tends to be more flexible. In components managing complex form or UI state, reactive often feels more natural.

Destructuring pitfalls: why reactivity breaks

This is where teams run into real trouble. When you destructure a reactive object, you lose reactivity:

typescript
import { reactive } from 'vue';

export default {
  setup() {
    const state = reactive({
      count: 0,
      message: 'Hello'
    });

    // DANGER: Destructuring breaks reactivity
    const { count, message } = state;
    
    // count and message are now plain numbers and strings
    // Mutating them won't trigger re-renders
    count++; // This does nothing meaningful
    message = 'World'; // This doesn't update the template

    return { count, message }; // ❌ NOT reactive
  }
};

Why? Because count and message are now just the values, not references to the reactive properties. The Proxy intercepts reads of state.count, but not reads of the local count variable.

The solution is toRefs, which converts each property into a separate ref:

typescript
import { reactive, toRefs } from 'vue';

export default {
  setup() {
    const state = reactive({
      count: 0,
      message: 'Hello'
    });

    // toRefs creates a ref for each property
    const { count, message } = toRefs(state);

    const increment = () => {
      count.value++; // ✅ Reactive
    };

    // In template, Vue unwraps refs automatically
    return { count, message, increment };
  }
};

This is especially important when building composables that return state:

typescript
// useCounter.ts
import { reactive, toRefs } from 'vue';

export function useCounter() {
  const state = reactive({
    count: 0,
    doubled: computed(() => state.count * 2)
  });

  return {
    ...toRefs(state),
    increment: () => state.count++
  };
}

// In your component
import { useCounter } from './useCounter';
export default {
  setup() {
    const { count, doubled, increment } = useCounter();
    return { count, doubled, increment }; // ✅ All reactive
  }
};

Shallow refs: an escape hatch for large objects

Sometimes you have a large object that you never mutate—you only replace it wholesale. Setting up full reactivity tracking for every nested property is wasteful. shallowRef is your escape hatch.

shallowRef only tracks changes to the .value property itself, not nested properties:

typescript
import { shallowRef } from 'vue';

export default {
  setup() {
    // A large data structure—trees, deep analytics, etc.
    const largeDataSet = shallowRef({
      nodes: [...1000 items...],
      edges: [...thousands...],
      metadata: { /* deeply nested */ }
    });

    const refreshData = async () => {
      // Fetching a new dataset
      const newData = await fetchData();
      largeDataSet.value = newData; // ✅ Reactive, only replaces top level
    };

    const mutateDeeply = () => {
      // This DOES NOT trigger re-renders
      largeDataSet.value.nodes[0].name = 'changed';
    };

    return { largeDataSet, refreshData, mutateDeeply };
  }
};

In dashboards, trading platforms, and real-time analytics where you're polling for new datasets and replacing them entirely, shallowRef can reduce memory overhead and improve frame rates. Just remember: mutations inside the object won't be reactive, only replacement of .value itself.

If you do need to trigger re-renders after a deep mutation, use triggerRef:

typescript
import { shallowRef, triggerRef } from 'vue';

export default {
  setup() {
    const data = shallowRef({ items: [] });

    const addItem = (item) => {
      data.value.items.push(item);
      triggerRef(data); // Force re-render
    };

    return { data, addItem };
  }
};

Common mistakes

  • Forgetting to unwrap ref in script setup: In <script setup>, you still need .value in JavaScript, even though templates auto-unwrap it. Don't forget: count.value++, not count++.
  • Destructuring reactive without toRefs: This is the most common mistake. Always use toRefs when destructuring or spreading reactive objects.
  • Adding properties dynamically to reactive objects: Vue won't track new properties. Either define them upfront or use Object.assign(state, { newProp: value }).
  • Mixing reactive and ref without consistency: In a single composable, decide whether you're using reactive objects or individual refs. Mixing them makes code harder to reason about.
  • Ignoring shallowRef for performance-critical data: If you're working with large datasets and only replacing them, not mutating them, using full reactivity is wasteful.

Key takeaways

  • Vue uses Proxy objects to intercept property access and mutations, automatically tracking dependencies without you writing dependency arrays.
  • Use reactive for objects you mutate in place; use ref for primitives and values you replace entirely.
  • Always use toRefs when destructuring reactive objects or spreading them into composable returns.
  • shallowRef is a performance optimization for large objects you only replace, not mutate.
  • Remember that new properties added after object creation won't be reactive—define your reactive shape upfront.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles