Vue's reactivity is the part of the framework that feels closest to magic. You write const count = ref(0), you change count.value, and the template updates. You add a property to a reactive object — sometimes it works, sometimes it doesn't. You destructure a ref and reactivity disappears.

There's no magic. There's a Proxy, a small dependency graph, and an effect scheduler. Once you see those three pieces, every "why is this not updating?" bug stops being a mystery and starts being a debuggable problem.

This article is the practical version of how Vue 3 reactivity works under the hood — enough to make you a faster debugger, without the full source-code archaeology.

The Three Pieces You Need To Know

1. A Proxy wraps every reactive object. When you read a property, the Proxy intercepts the read and remembers which effect was running at the time. When you write a property, the Proxy intercepts the write and re-runs every effect that read that property.

2. An effect is any function that re-runs when its dependencies change. That's watch, watchEffect, computed, and the render function of every component. They all use the same primitive (ReactiveEffect).

3. The scheduler decides when effects run. Asynchronously by default — Vue queues effects and flushes them in a microtask after the current synchronous code finishes, so multiple writes batch into a single render. Watchers can opt out per-call with flush: 'sync' when you need immediate behaviour, and await nextTick() is the user-facing way to wait for the queued flush to land.

That's the whole thing. Three primitives — Proxy, effect, scheduler — and the rest is implementation detail.

What ref Actually Does

ref(value) is just a wrapper. It returns an object with a .value getter and setter, which are themselves reactive. For primitives (string, number, boolean), this wrapper is necessary — you can't put a Proxy on a primitive, so Vue puts the primitive inside an object that itself is reactive.

TypeScript
import { ref } from 'vue'

const count = ref(0)

console.log(count)         // RefImpl { _value: 0, ... }
console.log(count.value)   // 0 — getter logs the access, registers the effect
count.value = 1            // setter triggers any effect that read .value

The reason count.value triggers re-renders and count (without .value) doesn't is getter access. The Proxy / getter intercepts reads. If you destructure (const { value } = count), you read once at destructuring time and never again — reactivity is broken.

In templates, Vue's compiler unwraps refs automatically ({{ count }} works without .value). In <script setup>, refs returned from setup are unwrapped at the template binding level. Inside JavaScript, you always need .value — there's no automatic unwrap.

What reactive Actually Does

reactive(obj) returns a Proxy over obj. Every property access goes through the Proxy. Nested objects get wrapped recursively (deep reactivity).

TypeScript
import { reactive } from 'vue'

const state = reactive({ count: 0, user: { name: 'Ann' } })

state.count++              // triggers effects that read state.count
state.user.name = 'Bob'    // triggers effects that read state.user.name

Two important constraints:

  • Destructuring breaks reactivity. const { count } = state gives you a number, not a reactive reference. Reads of count after that point don't go through the Proxy.
  • Replacing the whole object breaks reactivity. state = newState doesn't work — you've reassigned the local binding, not the underlying object. Mutate properties instead, or use Object.assign(state, newState).

The destructuring trap is the single most common Vue 3 reactivity bug I see. The fix is toRefs:

TypeScript
import { reactive, toRefs } from 'vue'

const state = reactive({ count: 0, user: { name: 'Ann' } })
const { count, user } = toRefs(state)
// count and user are now refs that stay in sync with state

toRefs walks the object and replaces each property with a ref bound to that property. Reads and writes still go through the original Proxy.

The Track / Trigger Cycle

When an effect runs (a render, a watchEffect, a computed), Vue stores a reference to it in a global "current effect" slot. As the effect reads reactive properties, the Proxy get trap calls track() — registering the effect as a dependent of that property.

When a property is written, the Proxy set trap calls trigger() — running every effect registered for that property.

Text
const count = ref(0)
const double = computed(() => count.value * 2)  // effect runs, reads count → tracks
count.value = 5                                   // triggers double's effect
console.log(double.value)                         // 10

This is also why computed only recomputes when its dependencies change — it's just an effect that updates a cached value. If nothing it tracked has triggered, it returns the cache.

A debugging map showing a reactive object as a Proxy, three effects (render, computed, watch) registered as dependents in a small dependency graph, and arrows showing how a single property write triggers each one in turn through the scheduler.
Three effects, one shared property, one trigger fan-out.

Why Some Updates Don't Re-render In Vue 2 (And Why Vue 3 Fixed It)

Vue 2 used Object.defineProperty for reactivity. It could only intercept property access, not property addition. Adding a new key to a reactive object, or pushing to an array by index, didn't trigger updates — you needed Vue.set or this.$set.

Vue 3's Proxy doesn't have that limitation. Adding a key, deleting a key, modifying an array by index, modifying Map/Set — all of these trigger as expected. This is one of the quietest but most useful improvements in Vue 3.

TypeScript
const list = reactive<string[]>([])
list.push('first')          // ✅ works in Vue 3
list[5] = 'sparse'           // ✅ works
list.length = 0              // ✅ works

const obj = reactive<Record<string, number>>({})
obj.newKey = 42              // ✅ works
delete obj.newKey            // ✅ works

If you're migrating a Vue 2 codebase, you can delete every Vue.set call and most array-index assignments will start working as you'd expect.

Shallow Variants: When Deep Is Too Expensive

reactive wraps every nested object. For most app state, that's fine. For very large data structures (a 50,000-row table, a 3D scene graph, a third-party class instance), the recursive wrapping costs memory and time.

The shallow variants opt out:

  • shallowRef(value).value is reactive, but if .value is an object, its properties are not.
  • shallowReactive(obj) — top-level keys are reactive, but nested objects aren't.
TypeScript
import { shallowRef } from 'vue'

const bigTable = shallowRef({ rows: hugeArray })

// reactivity triggers when you replace the whole object
bigTable.value = { rows: newArray }

// but not when you mutate inside
bigTable.value.rows.push(newRow)   // does not trigger

The discipline that pairs with shallow refs is immutable updates — replace the reference instead of mutating. Slightly more work, dramatically cheaper for large data.

markRaw(obj) is the related escape hatch: it permanently opts an object out of reactivity. Useful for class instances from third-party libraries (chart objects, video players) where the framework's deep traversal would break the lib.

The Effect Scheduler And Update Timing

Vue batches effect runs. When you write state.count++ three times in a row, Vue doesn't re-run the render three times — it queues the render once and flushes after the current synchronous code finishes (in a microtask).

TypeScript
import { ref, nextTick } from 'vue'

const count = ref(0)
count.value++
count.value++
count.value++
// render hasn't happened yet — the DOM still shows 0

await nextTick()
// now the DOM reflects 3

This is why await nextTick() is needed in tests after a state change. The state has changed, but the DOM hasn't caught up yet.

For watchers, you can control the flush timing:

TypeScript
watch(count, () => {}, { flush: 'pre' })   // before component re-renders (default)
watch(count, () => {}, { flush: 'post' })  // after the DOM has updated
watch(count, () => {}, { flush: 'sync' })  // immediate, synchronous

'sync' is rarely the right choice — it disables batching for that watcher and can cause infinite loops. Reach for it only when you need DOM measurement during the change.

A Few Reactivity Bugs You'll Definitely Hit

After enough Vue 3 code, the same issues show up:

  • Destructuring a reactive object. Use toRefs or storeToRefs for Pinia.
  • Forgetting .value outside templates. if (count > 0) instead of if (count.value > 0) — TypeScript catches this; plain JS doesn't.
  • Using ref for objects when reactive would be cleaner. Both work, but ref({ ... }).value.foo is awkward; state.foo is nicer.
  • watch not firing because the watched ref is destructured. Same root cause as the first bullet.
  • Adding markRaw to fix a perf problem you didn't measure. Profile first; markRaw is a sharp tool.
  • Mutating a shallowRef's nested data and expecting reactivity. Shallow means shallow.

A Mental Model In One Sentence

Vue's reactivity is a Proxy that records which effect read what, plus a scheduler that re-runs the right effects when the right properties change. Everything else — ref, reactive, computed, watch, the scheduler flush options — is a thin layer on top of those two primitives. Once you see them, the framework stops being mysterious and starts being a small, predictable machine.