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.
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).
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 } = stategives you a number, not a reactive reference. Reads ofcountafter that point don't go through the Proxy. - Replacing the whole object breaks reactivity.
state = newStatedoesn't work — you've reassigned the local binding, not the underlying object. Mutate properties instead, or useObject.assign(state, newState).
The destructuring trap is the single most common Vue 3 reactivity bug I see. The fix is toRefs:
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.
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.

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.
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)—.valueis reactive, but if.valueis an object, its properties are not.shallowReactive(obj)— top-level keys are reactive, but nested objects aren't.
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).
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:
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
reactiveobject. UsetoRefsorstoreToRefsfor Pinia. - Forgetting
.valueoutside templates.if (count > 0)instead ofif (count.value > 0)— TypeScript catches this; plain JS doesn't. - Using
reffor objects whenreactivewould be cleaner. Both work, butref({ ... }).value.foois awkward;state.foois nicer. watchnot firing because the watched ref is destructured. Same root cause as the first bullet.- Adding
markRawto 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.




