Skip to main content

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: Proxy intercepts property reads and writes, builds a dependency graph per render, triggers only affected components.
  • Vue 3 uses ES6 Proxy; Vue 2 used Object.defineProperty and missed dynamically added properties.
  • Decision rule: ref() for primitives and simple values; reactive() for complex nested objects.
  • Templates auto-unwrap ref, so no .value needed in HTML.

Quick example

vue
<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 = newCount stays clean.
  • Complex nested object with many properties: reactive() - no .value overhead.
  • Derived data that should cache: computed() - recalculates only when dependencies change.
  • Async or external data: ref() plus await handles 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

js
// 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 correctly

The 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

js
const count = ref(0) count++ // Wrong - reassigns the Ref object reference count.value++ // Correct

Templates auto-unwrap, scripts do not. That asymmetry catches almost everyone at least once.

3. Replacing an entire reactive array

js
// 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

js
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 - reactive

This 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

html
<!-- 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() wraps reactive for SSR-safe shared state across components.
  • Pinia: defineStore uses ref and reactive as the basis for all store definitions.
  • VueUse: useStorage keeps a ref in sync with localStorage reactively.
  • Quasar: form validation uses reactive error 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

vue
<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

vue
<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

vue
<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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?