How does reactivity work in Vue.js?
Vue.js reactivity automatically tracks which data your component reads during render and re-runs only the parts that depend on data that changed.
Theory
TL;DR
- Analogy: a spreadsheet formula. Change one cell, every formula that references it recalculates on its own.
- Core mechanism:
Proxyintercepts property reads and writes, builds a dependency graph per render, triggers only affected components. - Vue 3 uses ES6
Proxy; Vue 2 usedObject.definePropertyand missed dynamically added properties. - Decision rule:
ref()for primitives and simple values;reactive()for complex nested objects. - Templates auto-unwrap
ref, so no.valueneeded in HTML.
Quick example
<script setup>
import { ref } from 'vue'
const count = ref(0) // Reactive primitive - wrapped in .value
const increment = () => count.value++ // .value triggers the Proxy setter
</script>
<template>
<!-- Template auto-unwraps ref - no .value here -->
<button @click="increment">Count: {{ count }}</button>
</template>When you click the button, count.value++ hits the Proxy setter. Vue finds every template and computed that read count during the last render and re-runs them. Nothing else updates.
How the Proxy tracks dependencies
During render, Vue wraps your data in an ES6 Proxy. Every time a template or computed reads a property, the Proxy fires a track() call that registers: "this effect depends on this property." Every write fires trigger() that schedules re-renders for all registered effects.
Internally, dependencies live in a WeakMap<object, Map<key, Set<effect>>>. That structure lets Vue look up exactly which effects to re-run for a specific property change, without touching unrelated state. The whole system runs in plain browser JS, no runtime compiler hooks needed.
Vue 2 did the same with Object.defineProperty getters and setters. That worked for pre-defined properties but missed new properties added after initialization and direct array index assignments like arr[0] = x. The Proxy approach in Vue 3 covers all of that transparently.
Key difference between ref() and reactive()
ref() wraps any value (primitive or object) in a container object with a .value property. The Proxy sits on that container. reactive() converts the entire object into a Proxy directly, so you access properties without .value.
Both give deep reactivity for nested objects. The practical split: use ref for things you might replace entirely (count.value = 0), use reactive for objects where you mutate properties in place.
When to use
- Primitive (number, string, boolean):
ref()- the only option that works. - Single value you might replace:
ref()-count.value = newCountstays clean. - Complex nested object with many properties:
reactive()- no.valueoverhead. - Derived data that should cache:
computed()- recalculates only when dependencies change. - Async or external data:
ref()plusawaithandles uninitialized state naturally. - Objects you never want tracked:
markRaw()- opt out of reactivity entirely.
Common mistakes
1. Pushing a plain object into a reactive array
// Wrong - the new object is not reactive
state.items.push({ count: 0 })
state.items[0].count++ // UI does not update
// Fix - wrap in reactive()
state.items.push(reactive({ count: 0 }))
state.items[0].count++ // Updates correctlyThe outer array is proxied, but the object you pushed is a plain JS object. Mutations to its properties have no Proxy to intercept them.
2. Forgetting .value in script
const count = ref(0)
count++ // Wrong - reassigns the Ref object reference
count.value++ // CorrectTemplates auto-unwrap, scripts do not. That asymmetry catches almost everyone at least once.
3. Replacing an entire reactive array
// Wrong - breaks the Proxy chain
state.arr = []
// Fix - mutate in place
state.arr.length = 0
// or
state.arr.splice(0)Reassignment creates a new plain array and drops the Proxy. Anything that was watching the old reference stops updating.
4. Destructuring a reactive object
const state = reactive({ count: 0 })
const { count } = state // count is now a plain number - not reactive
// Fix - use toRefs()
import { toRefs } from 'vue'
const { count } = toRefs(state) // count is now a ref - reactiveThis is the most common footgun with reactive(). It looks a lot like React's destructured useState, but the behavior is different. The moment you pull a property out, you lose the Proxy connection.
5. Index keys in v-for
<!-- Wrong - DOM reorder breaks dependency tracking -->
<li v-for="(item, i) in list" :key="i">
<!-- Fix - stable identity key -->
<li v-for="item in list" :key="item.id">Real-world usage
- Nuxt.js:
useState()wrapsreactivefor SSR-safe shared state across components. - Pinia:
defineStoreusesrefandreactiveas the basis for all store definitions. - VueUse:
useStoragekeeps arefin sync withlocalStoragereactively. - Quasar: form validation uses
reactiveerror objects that update inline. - VitePress: reactive search filters on static markdown content via
computed.
Follow-up questions
Q: What is the difference between ref() and reactive()?
A: ref() wraps any value in a .value container and works with primitives. reactive() proxies an object directly with no .value. Both are deeply reactive. The main split: ref when you might replace the value entirely, reactive when you mutate properties in place.
Q: How does Vue detect changes in arrays?
A: The Proxy traps length sets and index assignments directly. Methods like push, pop, and splice mutate the proxied array, so the Proxy intercepts each operation and triggers dependencies.
Q: What happens when you assign a non-reactive object to a reactive property?
A: The property value becomes a plain object. Mutations to its nested properties have no Proxy to intercept them, so the UI will not update. Fix: wrap the assigned value with reactive() before assigning.
Q: Why did Vue 3 move from Object.defineProperty to Proxy?
A: Object.defineProperty only wraps properties that existed at definition time. Adding a new property later, or setting an array by index (arr[0] = x), bypassed the setter entirely. Proxy wraps the object itself, so any operation including new properties, deletions, and index sets gets intercepted automatically.
Q: How would you debug lost reactivity in a large production app?
A: Start with Vue DevTools and inspect component state. If a value shows there but the UI does not update, the template reference is stale. Check for destructured reactive objects missing toRefs. Look for markRaw() calls that might have opted out a shared object. The DevTools timeline shows which effects fired, which narrows down where the dependency chain broke.
Examples
Basic: counter with ref
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<button @click="count.value++">Clicked {{ count }} times</button>
</template>count starts at 0. Each click increments .value, the Proxy setter fires, Vue re-renders the button text. The template uses {{ count }} without .value because templates auto-unwrap refs.
Intermediate: todo list with nested reactive state
<script setup>
import { reactive, ref } from 'vue'
const todos = reactive({ list: [], filter: 'all' })
const newTodo = ref('')
const addTodo = () => {
if (!newTodo.value.trim()) return
todos.list.push({ id: Date.now(), text: newTodo.value, done: false })
newTodo.value = ''
}
const toggle = (id) => {
const todo = todos.list.find(t => t.id === id)
if (todo) todo.done = !todo.done
}
</script>
<template>
<input v-model="newTodo" @keyup.enter="addTodo" placeholder="New task" />
<ul>
<li v-for="todo in todos.list" :key="todo.id">
<input type="checkbox" :checked="todo.done" @change="toggle(todo.id)" />
<span :style="{ textDecoration: todo.done ? 'line-through' : 'none' }">
{{ todo.text }}
</span>
</li>
</ul>
</template>todos is a reactive object, so todos.list, todos.filter, and every property of items inside the list are all proxied. Adding an item updates the list. Toggling done crosses out the text. Notice todo.id as the key instead of the index - this prevents stale DOM references when items reorder.
Advanced: computed with dependency caching
<script setup>
import { reactive, computed } from 'vue'
const todos = reactive({
list: [
{ id: 1, text: 'Buy milk', done: false },
{ id: 2, text: 'Write tests', done: true }
]
})
// Recomputes only when todos.list changes
const remaining = computed(() =>
todos.list.filter(t => !t.done).length
)
const completeAll = () => {
todos.list.forEach(t => { t.done = true })
}
</script>
<template>
<p>{{ remaining }} tasks left</p>
<button @click="completeAll">Complete all</button>
</template>computed() registers its own effect during evaluation. The first time remaining is read, it tracks every property access inside the getter. After completeAll sets all done to true, Vue triggers the remaining effect, recomputes the value, and updates the paragraph. If nothing in todos.list changed between renders, computed returns the cached result without re-running the filter.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.