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:
// 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.
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:
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:
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:
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:
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:
// 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:
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:
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
refin script setup: In<script setup>, you still need.valuein JavaScript, even though templates auto-unwrap it. Don't forget:count.value++, notcount++. - Destructuring reactive without
toRefs: This is the most common mistake. Always usetoRefswhen 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
shallowReffor 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
Proxyobjects to intercept property access and mutations, automatically tracking dependencies without you writing dependency arrays. - Use
reactivefor objects you mutate in place; usereffor primitives and values you replace entirely. - Always use
toRefswhen destructuring reactive objects or spreading them into composable returns. shallowRefis 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.






Comments
Loading comments…