Skip to content
intermediate Phase 4 · Computed Properties and Watchers

Watching Sources with watch

Run side effects when a ref, getter, or reactive object changes.

45m
0 problems
Topic Progress 0%

Four Shapes of a Source: Choosing What the Watcher Listens To

Computed values derive; they cannot call an API, write to the URL, or start a timer. When the response to a state change is an action rather than a value, watch is the tool. Its signature is watch(source, callback, options?), and everything begins with the shape of source — there are four, each with different tracking semantics.

1. A ref. The minimal case:

const userId = ref(1)
watch(userId, (newId, oldId) => {
  console.log(`user ${oldId} → ${newId}`)
})

The watcher depends on the ref's own value slot. Writing userId.value = 2 fires the callback with real snapshots: 2, then 1. Crucially, mutating properties of an object inside the ref does not fire it without deep — the dependency is the ref, not the object's properties.

2. A reactive object. Watching the object itself watches every property, deeply, by default:

const state = reactive({ page: 1, filters: { status: 'open' } })
watch(state, (newState, oldState) => {
  // any mutation anywhere — state.page = 2 OR state.filters.status = 'closed'
})

You do not pass deep: true; it is implied. The catch lives in the callback arguments: newState and oldState are the same object reference — Vue hands you the reactive proxy before and after the mutation, and the "before" has already been mutated by the time you look at it. History does not exist for a reactive object source; the next chapter shows what to watch instead when it matters.

3. A getter. This is the precision instrument — the most-used form in real apps:

watch(
  () => props.productId,        // track exactly this slice
  (newId, oldId) => fetchProduct(newId)
)

Vue invokes the getter once to collect dependencies — here, props.productId — and re-invokes it after those change, passing the new and previous return values. Watching a computed-grade slice (() => state.user.address.city, () => items.value.filter(i => i.pinned).length) is the standard way to avoid the whole-object form's over-firing and its aliased old value.

4. An array of sources. One effect, several triggers:

watch([userId, () => props.tab], ([newUser, newTab], [oldUser, oldTab]) => {
  loadPanel(newUser, newTab)
})

Each element may be any of the first three shapes. The callback receives parallel arrays of new and old values, positionally matched to the sources. This beats maintaining three separate watchers that call the same loading function — and dedupes correctly when several sources change in the same tick.

Source Tracks deep needed Old value behavior
ref the ref's value slot only for nested mutations inside its object true snapshot per change
reactive object every property implied, always deep same reference — no history
() => expr what the getter reads if the getter returns an object you need traversed previous return value
[a, () => b] union of elements per element arrays of old values, positionally aligned

One scheduling fact applies to every row: multiple synchronous mutations coalesce. Write count.value = 2; count.value = 3; count.value = 4 in one function and a pre-flush watcher fires once — new value 4, old value the value from before the batch (1). Watchers observe effective state transitions, not every intermediate assignment. That is usually what you want for effects like fetching or logging; if you truly need every mutation, a flush: 'sync' watcher can observe them, at the cost of running your callback on each one.

Inside the Callback: Old Values, Cleanup, and the Coalescing Contract

The callback receives (newValue, oldValue, onCleanup). Each argument carries a trap or a superpower, and interviews love all three.

The old-value spectrum. How much history you get depends on the source, and the differences are larger than they look:

// ref source — honest snapshots
const page = ref(1)
watch(page, (n, o) => { /* n=2, o=1 */ })
page.value = 2

// getter source — previous return value
watch(() => `${user.value.first} ${user.value.last}`, (n, o) => {
  // 'Grace Hopper', 'Ada Lovelace' — two strings, both real
})

// reactive object source — the alias trap
const s = reactive({ n: 1 })
watch(s, (n, o) => {
  // n === o — the same proxy; reading o.n gives the NEW value
})
s.n = 2

The reactive-object alias is the debugging trap: code that logs "was X, now Y" reports Y twice, and diffing oldValue against newValue always shows no change. When you need the previous value of a field, watch a getter of that field instead of the container.

The coalescing contract. Watchers are jobs in the render scheduler: several mutations of a watched source inside one tick collapse into a single callback invocation with the final value. Two consequences worth stating in review:

  • Rapid interactions produce one effect. Ten keystrokes mutating query in one frame — one fetch, with the last query. Add a watcher-level debounce only when you need to wait out a pause in typing, not to deduplicate what the scheduler already deduplicates.
  • The old value is the pre-batch value, so new - old reflects the net transition, not each step. Counters and pagination math come out right; anything expecting every increment will not.

The cleanup protocol. The third argument registers a function that Vue calls before the next callback invocation and when the watcher stops:

watch(query, (q, _old, onCleanup) => {
  const controller = new AbortController()
  onCleanup(() => controller.abort())      // cancel the in-flight request
  fetch(`/api/search?q=${q}`, { signal: controller.signal })
    .then(res => res.json())
    .then(results => { hits.value = results })
})

Without the cleanup, an out-of-order response can overwrite fresh data: type vu, the slow vu request returns after the fast vue one, and the list shows results for the wrong query. The abort makes each invocation the only writer. This protocol — and its watchEffect counterpart — is the standard answer to "how do you prevent race conditions in search-as-you-type?"

Errors in the callback are not swallowed: they propagate to onErrorCaptured hooks and then to app.config.errorHandler, so a failing effect is visible in your global error reporting rather than disappearing in the scheduler.

One discipline note: keep the callback small. A watcher whose body loads data, updates two other states, and pushes analytics is untestable and hides which line re-triggered the cascade. If the callback mutates state that the watcher itself depends on, you are one step from the Maximum recursive updates exceeded loop discussed in the next chapter — the scheduler will tell you loudly if you cross the line.

immediate, deep, flush, once: Controlling When — and Whether — the Watcher Fires

The options object — watch(source, cb, { immediate, deep, flush, once }) — turns the default "fire after the next change, before render" behavior into precise timing. Each option exists because the default is wrong for a specific, common job.

immediate: true — include the current value. By default a watcher is silent until the source changes, which is a trap for loading logic: arrive on /users/42, userId never "changes", the fetch never runs. immediate invokes the callback at setup with the current value — and oldValue is undefined on that first call:

watch(userId, (id, oldId) => {
  if (id === undefined) return          // guard for the immediate first call
  loadUser(id)
}, { immediate: true })

The undefined-old argument is exactly how you detect the bootstrap invocation when the cleanup protocol needs to run on unmount only for real fetches.

deep: true — traverse nested mutations. Required when the source is a ref whose object's interior must trigger (watch(form, ..., { deep: true })), and redundant on a reactive object, where depth is implied. Its cost is real: on every triggering change the watcher re-reads the entire structure to re-collect dependencies, so watching a 200-item list deeply to react to one field is a performance bug. Prefer a getter over the exact slice — () => form.email — unless you genuinely need any-nested-change semantics, such as serializing the whole form.

flush — position relative to the render. Three values, one decision:

flush When the callback runs Reaching for it when
'pre' (default) before the component re-renders the effect updates state the template will show
'post' after the DOM has updated you measure or position against rendered elements
'sync' immediately per mutation almost never — it defeats batching; documented as discouraged

post is the one you must remember, because its use case cannot be met any other way — tooltips, popovers, canvas charts, and scroll calculations need the newly rendered DOM, which does not exist yet during a pre callback:

watch(dropdownPos, pos => placeTooltip(pos), { flush: 'post' })

once: true — fire once, then stop itself. For view analytics (watch(route, sendViewEvent, { once: true })) or any effect that must never repeat. The watcher deregisters after its first invocation, cleanup included.

Lifetime and stopping. Every watch returns a handle:

const stop = watch(source, cb)
// ...later
stop()

More importantly, the watcher is bound to the scope that created it: one created in setup() stops automatically when the component unmounts; one created inside effectScope() stops with the scope. This is why you do not normally manage lifetimes by hand — and why a watcher started in a module-level singleton (outside any scope) never stops, a leak that outlives every component that relied on it. Create watchers in component setup or inside an explicit effectScope you dispose yourself.

Finally, the guardrail: if the callback writes to its own source — watch(count, n => { count.value = n + 1 }) — the dev build reports Maximum recursive updates exceeded and points at the offending watcher. The watcher's contract is to respond to transitions; transitions it causes itself belong in a computed, or in a differently-scoped watcher with a stabilization condition. Next up: the sibling API that inverts who declares the dependencies — watchEffect.

Quiz

1. const s = reactive({ n: 1 }); watch(s, (n, o) => console.log(n === o)); s.n = 2. What is logged, and why?

Question 1 options

2. A route-driven panel must fetch when the page first loads and whenever the id changes. Which watcher is correct?

Question 2 options

3. You must position a tooltip against a freshly rendered element after dropdownPos changes. Which option does the job?

Question 3 options

4. In one synchronous block: count.value = 2; count.value = 3; count.value = 4, with watch(count, cb) at default flush. What does cb observe?

Question 4 options

Flashcards

Question

What are the four legal sources for watch, and what does each track?

Answer

1) A ref — tracks its value slot (nested object mutations need deep). 2) A reactive object — tracks every property, deep implied. 3) A getter () => expr — tracks whatever it reads, gives precise slices and real old values. 4) An array mixing the above — one callback receiving positionally matched arrays of new and old values.

Question

When is oldValue in a watch callback NOT a true snapshot?

Answer

When the source is a reactive object: new and old are the same proxy, already mutated, so diffing them shows no change. Watch a getter of the specific field instead. Also, first-call oldValue is undefined when immediate: true, and coalesced batches report the pre-batch value.

Question

Compare immediate, deep, flush, and once in one line each.

Answer

immediate: run at setup with the current value (oldValue undefined). deep: traverse nested mutations — implied for reactive objects, costly on large trees, prefer a getter slice. flush: 'pre' (default, before re-render) / 'post' (after DOM — for measuring and positioning) / 'sync' (per mutation, discouraged). once: fire a single time, then stop itself.

Question

How do you prevent out-of-order async responses from a watch callback?

Answer

Use the onCleanup third argument: create an AbortController per invocation and register controller.abort() with onCleanup. Vue runs it before the next invocation and on stop, so stale requests are cancelled and only the latest can write state. This is the standard search-as-you-type race guard.

Question

What is a watch's lifetime rule, and the classic leak?

Answer

A watcher created in setup() (or inside an effectScope) auto-stops when that scope is disposed — component unmount or scope.stop(). The returned handle (const stop = watch(...)) stops it manually. The classic leak: creating watchers at module scope or in an interval callback — no owning scope means they run forever across the app's life.

Revision Notes

Key Takeaways

  • 1. watch(source, cb, options) responds to a declared source; computed derives a value — effects that can't be expressed as a value belong in watch
  • 2. Old-value fidelity depends on the source: ref/getter give true snapshots, a reactive object passes the same mutated proxy twice
  • 3. Multiple synchronous mutations coalesce into one callback per flush with the net transition — watchers see effective state changes, not every assignment
  • 4. flush: 'post' is the only timing that sees updated DOM; immediate covers the initial value; deep is implied for reactive objects and should be replaced by getter slices on large structures
  • 5. Watchers auto-stop with their owning scope; module-scope watchers leak, and onCleanup is the race-condition guard for async callbacks

Interview Tips

  • • "When does a watcher callback run relative to render?" — default 'pre' is before the component re-renders; 'post' is after the DOM patch (needed for measuring); 'sync' per mutation is discouraged — knowing all three flush values signals real scheduler knowledge
  • • "Why is my old value identical to the new one?" — the source is a reactive object, passed by the same reference; show the getter-source fix for per-field history
  • • "Prevent race conditions in search-as-you-type" — onCleanup + AbortController per invocation so stale requests are cancelled before the next run and on stop
  • • "How do you stop watchers?" — returned handle, or rely on scope disposal from setup/effectScope; call out the module-scope watcher leak as the failure mode

Cheat Sheet

watch — Core Form

const stop = watch(source, (newVal, oldVal, onCleanup) => {
  onCleanup(() => cancelInFlightWork())   // runs before next call + on stop
  doSideEffect(newVal)
}, { immediate: false, deep: false, flush: 'pre', once: false })

Sources: ref · reactive object (deep implied; old === new reference!) · getter () => expr (best default) · array [a, () => b] (callback gets parallel arrays).

Old-value rules: ref/getter → real snapshot · reactive object → same mutated proxy · immediate first call → undefined · coalesced tick → pre-batch value.

Options:

Option Effect Use when
immediate callback at setup (old = undefined) effect must run for the initial value (route-driven loads)
deep traverse nested mutations ref-of-object fully changes semantics; reactive already deep; prefer getter slices on big trees
flush: 'post' after DOM update measure/position against rendered elements
flush: 'sync' per mutation almost never — defeats batching
once single fire, self-stops analytics, one-shot init

Scheduling: N synchronous writes → one callback, final new value, pre-batch old value. Debounce waits for a pause in changes; the scheduler already dedupes within a tick.

Races: third-arg onCleanup + AbortController → only the latest invocation may write.

Lifetime: setup/effectScope-owned → auto-stopped; module-scope watcher → leaks forever. Self-mutating callback → 'Maximum recursive updates exceeded'.

Continue Learning