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() uses Proxy traps, lazy deep conversion and a proxy cache, and what that means for object identity.
  • Choose between ref, reactive, shallowRef, shallowReactive, markRaw and customRef on purpose.
  • Predict when a computed recomputes, and why a computed whose value does not change does not wake its dependents.
  • Predict when watch and watchEffect callbacks run for each flush mode, and use onWatcherCleanup, once, deep: <number> and pause/resume.
  • Explain the scheduler queue, the parent-before-child ordering, and what nextTick() actually awaits.
  • Use effectScope to 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, a watch getter and a computed getter are all effects.
  • Dep — the list of subscribers attached to one reactive property (one key of one object, or the .value of 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. track only 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:

  1. 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.
  2. Reads outside an effect are not tracked. const n = state.count in plain setup() code reads once and never updates.
  3. Tracking is synchronous. Only reads made while the effect is on the stack count. A read after an await happens 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.

How a Vue write becomes a DOM update: a ref and a computed linked by track and trigger to four subscribers (a component render and pre, post and sync watchers), then one tick in which sync watchers run at once, and pre watchers, component updates and post watchers run in one microtask flush before nextTick resolves.


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, Map keys and === checks in your own code do not get that retry. Store ids, not objects, when identity matters, or compare with toRaw() 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() is false.
  • 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 #private fields, in practice. The proxy wraps fine, but a method called through the proxy runs with this set 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() or reactive() state. Wrap it with markRaw() or hold it in a shallowRef().


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 on ref() 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, typically v-model on 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 readonly in 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:

  1. 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.
  2. Post-flush callbacks: flush: 'post' watchers, onMounted / onUpdated hooks and template ref updates. At this point the DOM is patched.
  3. 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 a flush: 'pre' watcher to "wait for the DOM". It works, but flush: '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, or undefined. A composable can use it to decide whether it is safe to register cleanup.
  • onScopeDispose(fn) works in components too, where it is equivalent to onUnmounted for the setup scope. Prefer it in composables: it also works inside a standalone effectScope.
  • 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 (ref and shallowRef).
  • 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 count updates 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 createSignal to shallowRef, createMemo to computed, createEffect to watchEffect, and createRoot to effectScope. The model transfers directly; the deep ref is 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