Vue Deep Dive — 00 · Reactivity Internals
Most Vue bugs that reach production are not template bugs. They are reactivity bugs: a value that stopped updating after a refactor, a watcher that fires three times or never, a list that re-renders on every keystroke, a store that leaks between SSR requests. All of them come from one small engine — @vue/reactivity — and all of them become obvious once its model is clear. This post takes the engine apart: how reactive() builds on Proxy, how track and trigger build a dependency graph, why computed is lazy and when it recomputes, what flush: 'pre' really means, how the scheduler batches updates into one microtask, and how Vue's model compares with the signals in Solid, Preact, Angular and the TC39 proposal. Examples target Vue 3.5 (3.5.42 at the time of writing). Where the 3.6 release candidate (3.6.0-rc.7) differs, it is called out. Every behaviour claim with an output comment was checked by running it against 3.5.
Series map: 00 Reactivity Internals (this post) → 01 Composition API at Scale → 02 Compiler, Virtual DOM & Vapor Mode → 03 Ecosystem & Production (Pinia, Router, Nuxt). Future: see the extensions table in 03.
Learning Outcome
- Explain the effect / dependency / trigger model and draw the dependency graph for a component.
- Describe how
reactive()usesProxytraps, lazy deep conversion and a proxy cache, and what that means for object identity. - Choose between
ref,reactive,shallowRef,shallowReactive,markRawandcustomRefon purpose. - Predict when a
computedrecomputes, and why a computed whose value does not change does not wake its dependents. - Predict when
watchandwatchEffectcallbacks run for eachflushmode, and useonWatcherCleanup,once,deep: <number>and pause/resume. - Explain the scheduler queue, the parent-before-child ordering, and what
nextTick()actually awaits. - Use
effectScopeto own and dispose groups of effects outside components. - Recognise the common ways reactivity is lost, and fix each one.
- Place Vue's model next to signals and explain what Vue 3.6's alien-signals based core changes (and what it does not).
Chapter 1 — The model: effects, deps, triggers
Terminology
- Effect (subscriber) — a function that reads reactive state and must re-run when that state changes. A component's render, a
watchEffect, awatchgetter and acomputedgetter are all effects. - Dep — the list of subscribers attached to one reactive property (one key of one object, or the
.valueof one ref). - Track — during an effect run, record "this effect read this property" by linking the effect to the property's dep.
- Trigger — on a write, notify every subscriber in the property's dep.
- Active subscriber — the effect currently running.
trackonly does something while one exists; a read outside any effect is invisible to the system. - Dirty — a subscriber whose dependencies changed since its last run.
- Scheduler — the function an effect calls instead of re-running itself directly. Components and watchers use it to queue work.
The core loop
The official docs describe the engine in about twenty lines of pseudo-code. It is worth keeping this sketch in mind, because the production code is an optimised version of exactly this:
1
2
3
4
5
6
7
8
9
10
11
let activeEffect
const track = (target, key) => {
if (activeEffect) getSubscribersForProperty(target, key).add(activeEffect)
}
const trigger = (target, key) => getSubscribersForProperty(target, key).forEach((effect) => effect())
function whenDepsChange(update) {
const effect = () => { activeEffect = effect; update(); activeEffect = null }
effect()
}
Three properties fall out of this design and explain most of Vue's behaviour:
- Dependencies are discovered at run time. An effect depends on exactly what it read during its last run. A branch that was not taken creates no dependency.
- Reads outside an effect are not tracked.
const n = state.countin plainsetup()code reads once and never updates. - Tracking is synchronous. Only reads made while the effect is on the stack count. A read after an
awaithappens after the effect returned, so it is not tracked (Chapter 5).
The real data structures (3.5)
In 3.5 the storage is a WeakMap<target, Map<key, Dep>> called targetMap. Because the outer map is weak, an object that nobody references any more can be garbage-collected together with its deps.
The 3.5 release rewrote the internals around two ideas taken from Preact signals: version counting and doubly-linked lists. Each Dep has a version that increases on every trigger. Each subscriber keeps a linked list of Link nodes, one per dep it read; each Link also sits in the dep's list of subscribers, and records the dep version seen at the last run. On a re-run, links that were not visited again are removed, so a dependency from a branch no longer taken is dropped in O(1) per link rather than by clearing and rebuilding sets.
The Vue 3.5 announcement reports 56% lower memory use for the reactivity system with no behaviour change, and up to 10x faster operations on large, deeply reactive arrays in some cases. The pull request that did this measured 1,426k down to 631k in a test of 1,000 refs, 2,000 computeds and 1,000 effects.
Vue 3.6 (release candidate at the time of writing) replaces this core again with a refactor based on alien-signals, a signals library by a Vue core team member. The shape changes — the 3.6 package exposes internal functions such as link, unlink, propagate and checkDirty — but the public API (ref, reactive, computed, watch, effectScope) is the same. The rc release notes list no breaking change to the reactivity API.
Chapter 2 — reactive(): Proxy traps and their consequences
What the proxy intercepts
reactive(obj) returns a Proxy whose handler tracks reads and triggers writes:
| Trap | Operation | Action |
|---|---|---|
get |
state.x |
track(target, 'x'); if the value is an object, return reactive(value) |
set |
state.x = v |
write, then trigger(target, 'x') if the value changed (Object.is comparison) or the key is new |
has |
'x' in state |
track(target, 'x') |
deleteProperty |
delete state.x |
trigger if the key existed |
ownKeys |
Object.keys(state), for…in |
track a special iteration key, triggered on add or delete |
Collections (Map, Set, WeakMap, WeakSet) cannot be intercepted by traps alone, because their data lives in internal slots, so Vue wraps their methods (get, set, has, add, delete, forEach, iterators) with instrumented versions.
Lazy deep conversion
reactive() does not walk the object when you call it. A nested object is wrapped only when it is read through the proxy:
1
2
3
4
5
6
const raw = { inner: { v: 1 } }
const state = reactive(raw)
isReactive(raw.inner) // false: the raw object is never modified
isReactive(state.inner) // true: wrapped on access
This keeps reactive(hugeObject) cheap to create. The cost moves to access time: every nested read goes through a trap and a cache lookup.
Identity: the proxy cache
Vue keeps a WeakMap from raw object to proxy, so wrapping the same object twice returns the same proxy. But the proxy is not the original object, and identity checks across the boundary break:
1
2
3
4
5
6
7
const raw = { a: 1 }, s = reactive(raw)
s === raw; reactive(raw) === s; reactive(s) === s; toRaw(s) === raw // false, true, true, true
const item = { id: 1 }, list = reactive([item])
list[0] === item // false: list[0] is a proxy of item
list.includes(item) // true: includes/indexOf/lastIndexOf retry the search with raw values
Watch out:
Set,Mapkeys and===checks in your own code do not get that retry. Store ids, not objects, when identity matters, or compare withtoRaw()on both sides.
Arrays and length
Mutating array methods (push, pop, shift, unshift, splice) read and write length. If they tracked it, an effect that pushes into an array it also reads would trigger itself forever, so Vue pauses tracking inside those methods. Checked: watchEffect(() => { runs++; if (runs < 5) list.push(1) }, { flush: 'sync' }) runs once, not five times.
What reactive() refuses to wrap
- Primitives — a proxy needs an object. This is the reason
ref()exists. - Frozen or non-extensible objects —
reactive(Object.freeze({ a: 1 }))returns the frozen object unchanged;isReactive()isfalse. - Objects marked with
markRaw()— useful for class instances from third-party libraries (map SDKs, editors, Three.js scenes) that should never be proxied. - Classes with
#privatefields, in practice. The proxy wraps fine, but a method called through the proxy runs withthisset to the proxy, and the private-field check fails:
1
2
3
4
5
class Counter { #n = 0; inc() { this.#n++ } }
const c = reactive(new Counter())
c.inc()
// TypeError: Cannot read private member #n from an object whose class did not declare it
Common pitfall: putting an SDK instance, a class with private fields, or a large immutable dataset into
ref()orreactive()state. Wrap it withmarkRaw()or hold it in ashallowRef().
Chapter 3 — ref, reactive, shallowRef and friends
ref() is an object with a tracked .value
A ref is an object whose value getter calls track(ref, 'value') and whose setter calls trigger(ref, 'value'); the real RefImpl class holds its own Dep instead of going through targetMap. A ref works for any value, including primitives, and replacing .value is always reactive. When the value is an object, ref() passes it through reactive(), so ref({ n: 1 }).value.n++ is tracked too.
Unwrapping rules
| Where the ref is | Unwrapped automatically? |
|---|---|
Top-level binding in a template ({{ count }}) |
Yes |
Nested in a plain object, inside an expression ({{ obj.count + 1 }}) |
No, unless obj is reactive. A bare {{ obj.count }} still shows the value, because text interpolation unwraps its final result |
Property of a reactive() object |
Yes: state.count returns the number, and assignment writes through to the ref |
| Element of a reactive array | No: isRef(arr[0]) is true |
| Value in a reactive Map | No: isRef(map.get('k')) is true |
Choosing the primitive
| Primitive | Tracks | Use for |
|---|---|---|
ref(v) |
.value, and deeply if v is an object |
The default. Any value, any shape. |
reactive(obj) |
Every property, deeply, lazily | Grouped state you never replace wholesale (form models, local UI state) |
shallowRef(v) |
.value only |
Large immutable data, external class instances, anything you replace instead of mutate |
shallowReactive(obj) |
Top-level properties only | Objects whose nested values are opaque |
readonly(x) |
Nothing writable; reads still tracked | Exposing state from a composable or store without allowing writes |
markRaw(obj) |
Never proxied | SDK instances, class instances with #private fields |
customRef(factory) |
You call track() / trigger() yourself |
Debounce, throttle, storage-backed values |
shallowRef and immutable updates
A shallowRef ignores nested mutation:
1
2
3
4
5
const rows = shallowRef([{ id: 1, qty: 1 }])
rows.value[0].qty = 2 // no update
rows.value = rows.value.map(r => (r.id === 1 ? { ...r, qty: 2 } : r)) // update
triggerRef(rows) // or: mutate, then force a trigger by hand
For a table of 10,000 rows fetched from an API, shallowRef avoids creating a proxy per row and per nested object, and pushes you toward immutable updates, which pair well with v-memo (Post 02). Nuxt 4 made data from useFetch a shallowRef by default for this reason (Post 03).
customRef: a debounced ref
1
2
3
4
5
6
7
8
export const useDebouncedRef = <T>(initial: T, delay = 300) => {
let value = initial, timer: ReturnType<typeof setTimeout> | undefined
return customRef<T>((track, trigger) => ({
get() { track(); return value },
set(next) { clearTimeout(timer); timer = setTimeout(() => { value = next; trigger() }, delay) }
}))
}
Trick: the Vue docs recommend
ref()as the primary API.reactive()cannot be reassigned, cannot hold primitives, and loses reactivity when destructured. Teams that standardise onref()avoid a whole class of review comments.
Chapter 4 — computed: lazy, cached, and quiet when nothing changed
Laziness and caching
A computed is both a subscriber (it reads deps) and a dep (effects read it). It does not run until someone reads .value, and after that it re-runs only when a dependency changed and someone reads it again:
1
2
3
4
5
6
7
8
let calls = 0
const n = ref(1)
const double = computed(() => { calls++; return n.value * 2 })
// calls === 0: nothing read yet
double.value; double.value // calls === 1: cached after the first read
n.value = 2 // calls === 1: marked dirty, not recomputed
double.value // calls === 2: recomputed on demand
This is a push-pull design. A write pushes a "maybe dirty" notice down the graph; the actual recomputation is pulled by a read. Expensive computeds that nobody reads cost nothing.
Since 3.5, a computed subscribes to its own dependencies only while it has at least one subscriber, and unsubscribes when it loses the last one. A computed read only from plain code (no effect reading it) is never registered as a subscriber of its dependencies, so writes never notify it. On each read it first checks a global version counter, which skips recomputation outright when nothing reactive has changed anywhere, and otherwise compares the versions of its own deps. The same refactor removed the ability to "stop" a computed, which fixed old bugs where a computed could return a stale value after its component unmounted.
A computed that returns the same value does not wake its dependents
Since 3.4, when a computed recomputes and gets a value equal to the previous one (Object.is), its subscribers are not triggered:
1
2
3
4
5
6
7
8
9
const n = ref(1)
const parity = computed(() => n.value % 2)
let runs = 0
watchEffect(() => { parity.value; runs++ }) // runs === 1 (initial run)
n.value = 3; await nextTick() // parity recomputes: 1 → 1, runs === 1
n.value = 4; await nextTick() // parity 1 → 0, runs === 2
n.value = 6; await nextTick() // parity 0 → 0, runs === 2
The write to n marks parity as possibly dirty and notifies the effect. Before re-running, the effect asks parity whether it really changed; parity recomputes, compares with Object.is, and answers no. The effect is skipped. The rule: a computed whose new value is Object.is-equal to the old one does not trigger its subscribers. The practical corollary:
1
2
3
4
5
6
// Bad: a new array every time, so every dependent re-runs on every change of items
const activeIds = computed(() => items.value.filter(i => i.active).map(i => i.id))
// Better when dependents are expensive: return a primitive or a stable value
const activeKey = computed(() => items.value.filter(i => i.active).map(i => i.id).join(','))
The previous value (3.4+)
The getter receives the previous value, which lets a computed keep its last good result:
1
2
3
4
const n = ref(1)
const lastBig = computed((prev) => (n.value > 3 ? n.value : prev ?? 0))
// n = 1 → 0, n = 2 → 0, n = 5 → 5
Rules for computed getters
- No side effects. No writes to other state, no requests, no DOM work. Use a watcher.
- Do not mutate the returned value. It is a derived snapshot; mutating it changes nothing upstream and the next recompute throws the change away.
- Writable computed (
computed({ get, set })) is for adapters, typicallyv-modelon a transformed value. The setter should write the source, never the computed itself. - Writing to a read-only computed does not throw: Vue logs
[Vue warn] Write operation failed: computed value is readonlyin development and ignores the write.
Chapter 5 — watch and watchEffect
Sources and depth
1
2
watch(source, (value, oldValue, onCleanup) => { /* … */ }, options)
source can be a ref, a reactive object, a getter, or an array of those. The depth rules are asymmetric, and they are the most common source of "my watcher never fires":
| Source | Deep by default? |
|---|---|
A reactive() object: watch(state, cb) |
Yes: nested mutation fires the callback |
A getter: watch(() => state.inner, cb) |
No: fires only when the getter returns a different object |
A ref holding an object: watch(objRef, cb) |
No: fires on .value replacement only |
Any of the above with { deep: true } |
Yes |
With { deep: 1 } (3.5+) |
Only to that depth: top-level key replaced fires, a change two levels down does not |
Watch out: a deep watcher traverses the whole object on every run to register dependencies. On a large tree this is real CPU time. Prefer a getter that returns exactly what you care about, or
deep: <number>.
Options
Besides deep and flush (Chapter 6): immediate: true runs the callback once at creation with oldValue undefined; once: true (3.4+) runs it once and stops; onTrack / onTrigger are development-only debug hooks — put a debugger in onTrigger to see exactly which write re-ran a watcher.
The handle: stop, pause, resume (3.5+)
watch and watchEffect return a WatchHandle. It is callable (same as stop) and has pause() and resume(). A change made while paused is not lost: it is delivered once on resume():
1
2
3
4
const h = watch(src, () => runs++, { flush: 'sync' })
h.pause(); src.value++; src.value++ // runs === 0
h.resume() // runs === 1
Cleanup: onWatcherCleanup (3.5+)
The canonical use is cancelling a stale request when the input changes again:
1
2
3
4
5
6
7
8
9
import { watch, onWatcherCleanup } from 'vue'
watch(userId, async (id) => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort()) // must be called before any await
const res = await fetch(`/api/users/${id}`, { signal: controller.signal })
profile.value = await res.json()
})
The cleanup runs before the next run of the same watcher, and when the watcher stops. With id going 2 then 3, the order is run2, cleanup2, run3. onWatcherCleanup must be called synchronously, before the first await; after it there is no active watcher to attach to. The third callback argument, onCleanup, works the same way and has no such restriction.
watchEffect and await
1
2
3
4
5
6
watchEffect(async () => {
const a = x.value // tracked
await something()
const b = y.value // NOT tracked: the effect already returned
})
Checked: after the first run, changing y does not re-run the effect. Read every dependency before the first await, or use watch with an explicit source list.
watch vs watchEffect
watch |
watchEffect |
|
|---|---|---|
| Dependencies | Explicit source | Whatever the body reads |
| Runs at creation | Only with immediate: true |
Always |
| Old value | Yes | No |
| Risk | Forgetting deep |
Tracking more than intended |
| Best for | Reacting to one specific change | Syncing state to an external system |
Chapter 6 — The scheduler and nextTick
Why writes do not re-render immediately
A component's render effect does not re-run on each trigger. Its scheduler queues a job, and the queue is flushed in a microtask. Three synchronous writes cost one render and one pre watcher callback:
1
2
3
4
5
6
7
const c = ref(0)
watch(c, (v, o) => console.log(v, o))
c.value++; c.value++; c.value++
// nothing logged yet
await nextTick()
// logs once: 3 0
The watcher sees oldValue 0 and value 3; the intermediate values never exist from its point of view.
One flush, in order
The queue is flushed by a function scheduled with Promise.resolve().then(flushJobs). Inside one flush:
flush: 'pre'watcher callbacks and component update jobs, in one queue sorted by component id. A parent is created before its children, so it has a lower id and updates first. That guarantees a child is never rendered with props its parent is about to change, and lets the queue skip a child update that its parent's re-render already covered. Pre jobs for a component run just before that component's own update.- Post-flush callbacks:
flush: 'post'watchers,onMounted/onUpdatedhooks and template ref updates. At this point the DOM is patched. - If any of those queued more work, the flush loops.
flush: 'sync' watchers are outside the queue: they run inside trigger, before the write statement even returns. Checked on 3.5 with three watchers on the same ref:
1
2
3
4
5
a.value++
log.push('after set')
await nextTick()
// sync, after set, pre, post
| Flush | Runs | DOM state | Use for |
|---|---|---|---|
'sync' |
Inside the write | Not updated | Rare: invariants that must hold immediately. Defeats batching. |
'pre' (default) |
Before the owning component re-renders | Old | Deriving state that the render will use |
'post' |
After the DOM is patched | New | Measuring elements, third-party DOM libraries |
watchPostEffect and watchSyncEffect are aliases for watchEffect with flush set.
nextTick()
1
2
3
4
5
function nextTick(fn) {
const p = currentFlushPromise || resolvedPromise
return fn ? p.then(fn) : p
}
That is the actual 3.5 implementation, minus a this binding. nextTick() returns the promise of the flush that is pending or running, so awaiting it means "after this round of DOM updates". When no flush is pending, it resolves after an already-resolved promise — one microtask.
Common pitfall:
await nextTick()inside aflush: 'pre'watcher to "wait for the DOM". It works, butflush: 'post'says what you mean and avoids an extra hop.
Infinite loops
A job that re-queues itself — a watcher that writes its own source, an onUpdated hook that changes state the template reads — is caught in development after 100 recursive runs (RECURSION_LIMIT in the scheduler), with a "Maximum recursive updates exceeded" warning naming the component. In production the check is compiled out and the page simply spins.
Chapter 7 — effectScope: owning effects outside components
Inside setup(), every computed, watch and watchEffect is collected into the component's scope and stopped on unmount. Outside a component — a shared composable, a store, a test — nothing collects them, and they leak. effectScope() is the public version of that collector:
1
2
3
4
5
6
7
8
9
const scope = effectScope()
scope.run(() => {
const q = ref('')
watch(q, search) // collected by scope
const off = listen('resize', onResize)
onScopeDispose(off) // cleanup for non-reactive resources
})
scope.stop() // stops every effect created in run()
Useful details:
getCurrentScope()returns the active scope, orundefined. A composable can use it to decide whether it is safe to register cleanup.onScopeDispose(fn)works in components too, where it is equivalent toonUnmountedfor the setup scope. Prefer it in composables: it also works inside a standaloneeffectScope.effectScope(true)creates a detached scope that is not collected by the parent scope. Use it for state that must outlive the component that first created it.- Since 3.5 a scope can be paused and resumed (
scope.pause()/scope.resume()), which pauses every effect it owns.
The main consumer of effectScope(true) is the shared composable: state created by the first caller, kept alive by a detached scope, and disposed when the last caller unmounts. Post 01 builds one with reference counting.
Chapter 8 — How reactivity is lost
Every item below was a real production bug somewhere. The fix is always the same idea: keep a reference to the container (the proxy or the ref), not a copy of the value.
1. Destructuring a reactive object
1
2
3
4
5
6
const state = reactive({ x: 1 })
let { x } = state // x is the number 1, forever
const { x: xr } = toRefs(state)
state.x = 3
xr.value // 3: toRefs creates refs linked to the proxy
2. Passing .value into a composable
1
2
3
4
useTitle(title.value) // passes a string snapshot
useTitle(title) // passes the ref
useTitle(() => `${page.value} · Shop`) // passes a getter
Post 01 shows how to write composables that accept all three via MaybeRefOrGetter and toValue().
3. Replacing a reactive() object
state = reactive({ items: fresh }) rebinds a local variable; the template still holds the old proxy. Object.assign(state, { items: fresh }) mutates the same proxy and works. With a ref, state.value = { items: fresh } is reactive by design.
4. Destructuring props (before 3.5) and stores
Since 3.5, const { count } = defineProps() is compiled to __props.count and stays reactive (Post 01). Passing that destructured name straight to watch(count, …) is a compile error in 3.5; the compiler tells you to pass () => count. Pinia stores are reactive objects: const { total } = useCart() breaks, const { total } = storeToRefs(useCart()) works (Post 03).
5. Reads after await, identity checks, unproxyable objects
Only reads before the first await of an effect are tracked (Chapter 5). reactive([item])[0] !== item, so Map/Set keys, === checks and WeakMap caches see the proxy: compare ids or toRaw() values. Frozen and markRaw objects are returned as-is, and classes with #private fields break through the proxy (Chapter 2): all three usually belong in a shallowRef.
6. State created at module scope in SSR
1
2
3
// store.js
export const cart = reactive({ items: [] }) // one instance per server process
On the server, a module is evaluated once and shared by every request, so this leaks one user's cart into another user's page. Per-request state belongs in app.provide, Pinia (which creates a store per app instance) or Nuxt's useState (Post 03).
Trick: when a value "stops updating", ask one question: at the moment the effect ran, did it read a proxy property or a ref's
.value? If it read a plain variable holding a copy, nothing can ever trigger it.
Chapter 9 — Vue and signals
Same idea, different surface
A signal is a value container that tracks reads and notifies on writes. By that definition shallowRef is a signal, and the Vue docs compare it directly with Preact and Qwik signals. The frameworks differ mostly in API shape and in what re-runs:
| Read | Write | Deep objects | What re-runs on change | |
|---|---|---|---|---|
Vue ref |
count.value |
count.value = 1 |
Yes (via reactive) |
Component render (VDOM) or, in Vapor mode, a DOM binding |
Vue shallowRef |
count.value |
count.value = 1 |
No | Same |
| Solid | count() |
setCount(1) |
Stores, separate API | The DOM binding that read it |
| Preact Signals | count.value |
count.value = 1 |
No | Component, or text node when bound directly |
| Angular | count() |
count.set(1), count.update(f) |
No | Views marked for check |
| TC39 proposal | s.get() |
s.set(1) |
No | Nothing built in: Signal.subtle.Watcher lets a framework schedule |
The TC39 Signals proposal (Signal.State, Signal.Computed, Signal.subtle.Watcher) is at Stage 1. It standardises the graph, not the rendering. Nothing in Vue depends on it.
Where Vue is different
- Deep reactivity by default.
ref(obj)makes the whole object reactive via proxies. Most signal libraries are shallow and expect immutable updates. Vue gives you both (refandshallowRef). - Coarse re-render in the VDOM model. In classic Vue, fine-grained tracking decides which component re-renders; within the component, the virtual DOM diff (helped by the compiler's patch flags, Post 02) decides what changes. Solid and Svelte 5 skip the component level and update DOM nodes directly.
- Vapor mode closes that gap. A Vapor component compiles its template into direct DOM operations, each wrapped in a small render effect, so a change to
countupdates one text node. Post 02 shows the compiled output. - 3.6's alien-signals core. The 3.6 refactor changes the engine, not the model: the same push-pull graph, a leaner implementation. The code you write in this post is unchanged.
Trick: if you are coming from Solid or Preact, map
createSignaltoshallowRef,createMemotocomputed,createEffecttowatchEffect, andcreateRoottoeffectScope. The model transfers directly; the deeprefis the only extra concept.
Practice Exercises
Exercise 1 — Draw the graph
For this component, list every dep and every subscriber, then say what re-runs when filter changes and when one item's done flag changes.
1
2
3
4
5
6
const items = ref([{ id: 1, text: 'a', done: false }])
const filter = ref('all')
const visible = computed(() =>
filter.value === 'all' ? items.value : items.value.filter(i => i.done === (filter.value === 'done')))
// template: <li v-for="i in visible" :key="i.id">{{ i.text }}</li>
Solution
Deps: filter.value, items.value, the array's iteration/length, and each item's done (only read when filter is not 'all') and text and id (read by the render). Subscribers: visible (reads filter, items, and done when filtering) and the component render (reads visible.value, each i.text, each i.id). Changing filter dirties visible; the render re-runs and pulls a new array. Changing done while filter === 'all' triggers nothing that reads done — visible never read it in that branch — so nothing re-renders, which is correct because nothing visible changed. With filter === 'done', it dirties visible and the render.
Exercise 2 — Predict the log
1
2
3
4
5
6
7
8
const a = ref(0)
watch(a, () => log.push('pre'))
watch(a, () => log.push('post'), { flush: 'post' })
watch(a, () => log.push('sync'), { flush: 'sync' })
a.value++; a.value++
log.push('end')
await nextTick()
Solution
sync, sync, end, pre, post. The sync watcher runs inside each write (twice). The pre and post watchers are queued once each, because a queued job is not queued again, and run in the flush: pre first, then post-flush callbacks.
Exercise 3 — Fix the composable call
useFormatted(price.value) shows the first price forever. Fix it without changing the composable's callers more than necessary, and state what signature the composable should have.
Solution
Pass the ref or a getter: useFormatted(price) or useFormatted(() => price.value). The composable should accept MaybeRefOrGetter<number> and read it with toValue() inside a computed or watchEffect, so the read happens inside an effect every time.
Exercise 4 — Choose the primitive
For each, pick ref, reactive, shallowRef or markRaw: (a) a 50,000-row report you replace on each fetch; (b) a Mapbox map instance; (c) a login form with six fields; (d) a counter.
Solution
(a) shallowRef: no per-row proxies, and you replace rather than mutate. (b) markRaw (or a shallowRef holding it): the SDK must never be proxied. (c) reactive or a ref of an object: grouped, mutated in place, never replaced. (d) ref.
Exercise 5 — The async watcher
Rewrite this so a fast typist never sees results for an old query, and no request is left running after the component unmounts.
1
2
3
4
watchEffect(async () => {
results.value = await (await fetch(`/search?q=${q.value}`)).json()
})
Solution
1
2
3
4
5
6
7
8
9
10
11
watch(q, async (query) => {
const ctrl = new AbortController()
onWatcherCleanup(() => ctrl.abort())
try {
const res = await fetch(`/search?q=${encodeURIComponent(query)}`, { signal: ctrl.signal })
results.value = await res.json()
} catch (e) {
if ((e as Error).name !== 'AbortError') throw e
}
}, { immediate: true })
The cleanup aborts the previous request when q changes, and when the component unmounts (the watcher stops).
Cheat-sheet
| Need | Use |
|---|---|
| Any reactive value | ref(v) |
| Big data you replace, SDK objects | shallowRef(v) (+ triggerRef after in-place mutation) |
| Never proxy this | markRaw(obj) |
| Destructure without losing reactivity | toRefs(state) / toRef(state, 'key') |
| Derived value, cached | computed(() => …) — no side effects |
| React to one source | watch(source, cb) |
| Watch a nested field | watch(() => state.a.b, cb) |
| Limit deep traversal | { deep: 2 } (3.5+) |
| Cancel stale async work | onWatcherCleanup(() => ctrl.abort()) before any await (3.5+) |
| After DOM update | { flush: 'post' } or await nextTick() |
| Pause a watcher | const h = watch(…); h.pause(); h.resume() (3.5+) |
| Own effects outside components | effectScope(), scope.run(), scope.stop(), onScopeDispose() |
| Debug why something re-ran | onTrigger(e) { debugger } on a watcher or computed (dev only) |
What's next
Post 01 turns this engine into architecture: how to design composables whose inputs are refs or getters, how cleanup and effectScope fit into them, typed provide/inject, every <script setup> macro including reactive props destructure and defineModel, generic components, useTemplateRef, and async components with Suspense. The JavaScript Deep Dive series covers the language features this post leans on (Proxy, WeakMap, microtasks and the event loop), and the React Deep Dive series is a useful contrast: React re-runs the component function and compares, where Vue tracks what was read.
References
- Reactivity in Depth — the official model, pseudo-code and the connection to signals.
- Reactivity API: Core · Advanced —
watchoptions,WatchHandle,onWatcherCleanup,effectScope,customRef. - Watchers guide — flush timing, deep watchers, cleanup.
- Announcing Vue 3.5 — reactivity refactor and memory figures.
- vuejs/core PR #10397 — the 3.5 version-counting / linked-list refactor.
- vuejs/core releases — 3.6 alpha and rc notes (Vapor mode, alien-signals).
- alien-signals — the signals library the 3.6 core is based on.
- TC39 Signals proposal — Stage 1.
- MDN: Proxy — traps and invariants.










