Skip to content
intermediate Phase 4 · Computed Properties and Watchers

Choosing Computed vs watch

Decide between cached derivation and imperative side effects correctly.

30m
0 problems
Topic Progress 0%

If You Can Say B = f(A), Don't Watch A: The Derivation Argument

The most common architectural mistake in Composition API code is using a watcher to maintain a value that could have been derived. The two implementations look similar and one is wrong:

// WATCH version — hand-maintained copy
const first = ref('Ada')
const last = ref('Lovelace')
const full = ref('')
watch([first, last], ([f, l]) => { full.value = `${f} ${l}` }, { immediate: true })

// COMPUTED version — derivation
const full = computed(() => `${first.value} ${last.value}`)

Both render the same string today. The watch version carries four defects that only appear under change:

It creates a second mutable state. full is an ordinary ref — any code in the app can write full.value = 'oops' and the value silently diverges from first/last until the next source change snaps it back. The computed version is read-only by contract; a write attempt warns in dev. With computed, "full always equals f(first, last)" holds by construction; with watch, it holds by discipline.

It re-implements the reactivity graph by hand. Chain the derivation one level further — initials = full.split(' ').map(s => s[0]).join('') — and the watch version needs a second watcher wired to full, initialized by hand, ordering dependencies so the first watcher runs before the second. The computed version is one line, and invalidation propagates automatically through the chain: change first, and every computed downstream goes dirty lazily, recomputing at most once when read.

It recomputes eagerly and unconditionally. The watcher runs on every source change whether or not anything displays full — a background component, a route with no form, an SSR pass. The computed is lazy: no reader, no computation, and with multiple readers the getter runs once per change, not once per consumer.

It needs bootstrapping ceremony. immediate: true exists precisely because watch-based derivations must be seeded manually; forgetting it is the classic "works after I change a field, blank on first render" bug. Computed needs no seeding — its first read computes it.

The rule that collapses the whole decision: if the new state is expressible as a function of existing state, it is a value, and values are computed. Filtered lists, totals, formatted dates, display labels, validation booleans, sort orders — all derivations. A watch is only justified when the response to a change cannot be expressed as a return value. That is a smaller set than it first appears, and it is the subject of the next chapter.

There is a performance dimension interviewers occasionally probe. Before Vue 3.5, a long computed chain could invalidate repeatedly — each write rippling through the dependency graph in passes that grew with the chain's depth. The reworked reactivity core shipped in 3.5 batches those invalidations so a chain of derived computeds settles in a single pass, making the computed-per-derivation style measurably cheaper on deep forms and tables. The watch-maintained copy got no such fix, because a second ref is a second mutable state the scheduler must chase forever. The framework's investment goes where the contract is sound: derivations are computed's contract, and that is the complete answer to "why not just watch everything?"

One interview follow-up to be ready for: "isn't computed just a cached watch?" Mechanically they share the scheduler, but the contracts differ on every axis — lazy vs eager, read-only vs writable, single-cache vs state copy, no side effects vs purpose-built for them. Caching is one difference; the derivation-versus-effect boundary is the one that decides the design.

The Effects Only a watch Can Express: Fetching, Logging, Persisting, Measuring

Some responses to a state change have no return value. An API call is an action; a console line, a localStorage write, a chart redraw, and a websocket send are actions. Returning them from a computed is not merely inelegant — it breaks the getter's contract in ways that show up as missing analytics events and poisoned server renders. These are watch's home turf, and each has a characteristic shape.

Fetching on a key change — the effect with a race condition, so it carries the cleanup protocol from watchEffect through watch's third argument:

watch(() => route.params.id, (id, _old, onCleanup) => {
  const controller = new AbortController()
  onCleanup(() => controller.abort())
  loadProduct(id, controller.signal)
}, { immediate: true })

Debounced search — waiting for a pause in changes, which no scheduler provides on its own:

watch(query, (q, _old, onCleanup) => {
  const timer = setTimeout(() => runSearch(q), 300)
  onCleanup(() => clearTimeout(timer))     // each new keystroke resets the wait
})

Persistence and telemetry — effects whose entire purpose is a write somewhere outside the component tree:

watch(theme, t => localStorage.setItem('theme', t))
watch(route, r => analytics.pageView(r.path), { once: false })

Each of these, moved into a computed getter, fails twice over. First, invocation count becomes undefined: a lazy getter may run zero times — no reader means the analytics event never fires — or several times per change under re-renders and HMR, duplicating events. Second, side effects during render are hostile to SSR: the server render executes the getter and logs/persists server-side, then the client hydrates and does it again. The watcher runs in an effect scope, never during render, and its flush option even lets you choose when: flush: 'post' is the only correct timing for effects that measure the rendered DOM — tooltip placement, canvas charts, scroll-into-view — because the new layout does not exist yet during a pre callback.

The classification test, stated as one question: after the work is done, is there a value you will render, or is the work itself the point? A filtered list you display is a value; the fetch that populates the list is work. A formatted currency string is a value; the localStorage write of the user's currency preference is work. Real features use both, cleanly separated:

const visibleRows = computed(() => rows.value.filter(r => matches(r, query.value)))  // value
watch(query, (q, _old, onCleanup) => {            // work — persist search history
  const t = setTimeout(() => history.add(q), 1000)
  onCleanup(() => clearTimeout(t))
})

Two API details finish the toolkit. Vue 3.4 added { once: true } — a watcher that unregisters itself after its first callback, the declarative replacement for the manual stop-handle pattern:

watch(route, (r) => analytics.pageView(r.path), { once: true })

And onCleanup fires both before the watcher's next run and when the watcher itself is stopped, including component unmount — so an abort registered in cleanup covers the race and the teardown with one statement:

watch(() => route.params.id, (id, _old, onCleanup) => {
  const controller = new AbortController()
  onCleanup(() => controller.abort())   // next run AND unmount both abort
  loadProduct(id, controller.signal)
})

What cleanup cannot cover is work scheduled outside the watcher — a bare setInterval in setup belongs to nobody, which is precisely the ghost-work problem keep-alive makes impossible to ignore.

A code-review heuristic follows directly: a computed getter whose body contains await, fetch, console, localStorage, emit, or DOM writes is misplaced code — move the value half into a computed and the action half into a watch. And a watcher whose callback merely assigns x.value = expression of y.value is the inverse misplaced code — collapse it into a computed. The next chapter covers what happens when these boundaries are crossed in the looping direction, the one failure mode that takes the whole app down instead of one feature.

Maximum Recursive Updates Exceeded: Tracing the Self-Chasing Watcher

Both crossed boundaries — a side effect inside a computed, a derivation inside a watch — share a worst-case failure: the update graph cycles, and Vue's dev-mode guard halts the app with Maximum recursive updates exceeded, pointing at the component or watcher where the recursion was detected. The message names the detection site, not always the author, so tracing matters.

Pattern 1: the computed that writes.

// BROKEN — the getter writes to state its own render reads
const doubled = computed(() => {
  count.value++                 // write to a dependency
  return count.value * 2
})

Every read of doubled mutates count, which invalidates doubled, which the render re-reads, which mutates count again. The guard fires. The subtler variant writes other render state — a computed that sets document.title, pushes to a rendered list, or emits an event that mutates a parent the render depends on. The fix is structural, not cosmetic: a getter may read, never write. If the write has a reason, name it and put it in a watch — then check the write does not re-trigger that same watcher.

Pattern 2: the watcher chasing its own source.

// BROKEN — the callback writes the very ref it watches
watch(count, n => { count.value = n * 2 })

Change count once and the watcher writes, which re-queues the watcher, which writes again — 100 recursive updates later the guard fires. This is not always a bug in disguise: Vue explicitly allows converging self-triggers, where the callback runs a bounded number of times until the values stabilize:

// legal — converges after one extra run
watch(list, l => { if (l.length > 10) l.splice(0, l.length - 10) })

The callback runs again (it mutated its own source), finds the invariant satisfied, stops writing, and the cascade ends. The litmus test: does a second run of the callback produce another write? If yes forever — n * 2 never equals n — you have a diverging loop and must restructure: make the value a computed, add a stabilization condition (if (n === target) return), or break the cycle by watching a narrower source.

Pattern 3: the two-watcher circuit. Watcher A writes state that watcher B watches; B writes state A watches. Neither watcher names itself in the stack trace, and the error surfaces wherever the cycle's tail lands. The detection heuristic is the same one used for computed detach bugs from Reactivity Pitfalls: draw the write arrows — for every watcher, list what its callback writes, then check whether any written state reaches back to a watched source (directly, via a computed, or through a parent-child prop chain).

The trickiest loop to spot is not inside one component — it crosses the boundary. The child emits update:count; the parent's handler writes state that flows back down as a prop; the child's watcher on that prop emits update:count again. Every file reads as correct, and the cycle only appears when you trace write arrows through the props edge — which is why both components show up in the guard's stack trace while neither contains the bug alone. The fix is the DAG rule applied across the boundary: a watcher may not emit an event whose parent handler feeds the prop that same watcher reads. When the flow is genuinely bidirectional — a slider mirroring a value the parent normalizes — one side must be declared authoritative and the other must stop reacting to its own echoes, usually by narrowing the watch source to the user-driven half of the state.

The design rule that eliminates the entire class: the data flow graph should be a DAG with computeds as pure edges and watches as terminal effects. Derivations flow from state through computeds to the template; effects branch off toward the outside world (network, storage, DOM measurement, logs) and write back only into state the effect does not watch — or, when it must, into a state it watches with a provable stabilization condition. With that shape, the worst reactivity bug you can hit is a stale cache, and the component world begins from a state layer that behaves.

Quiz

1. You need a display string derived from first and last name refs. Why is computed categorically better than watch with immediate: true?

Question 1 options

2. Which of these correctly belongs in a watch callback rather than a computed getter?

Question 2 options

3. watch(list, l => { if (l.length > 10) l.splice(0, l.length - 10) }) — the watcher mutates its own source. What happens?

Question 3 options

4. The dev console shows 'Maximum recursive updates exceeded' pointing at your ProductPage component. How do you trace the cause?

Question 4 options

Flashcards

Question

State the one-question test for computed vs watch.

Answer

After the work is done, is there a value you will render, or is the work itself the point? If the new state is expressible as a function of existing state (B = f(A)), use computed — caching, laziness, and read-only enforce the invariant. If the response is an action — fetch, log, persist, measure, emit — use watch.

Question

Name four defects of maintaining a derived value with watch instead of computed.

Answer

1) A second mutable ref that any code can overwrite (computed is read-only). 2) The reactivity chain is rebuilt by hand — chained derivations need ordered watchers. 3) Eager recomputation even with no readers (computed is lazy, one run per change). 4) Bootstrapping ceremony — forget immediate: true and the initial value is blank.

Question

Why is a side effect inside a computed getter a double failure?

Answer

Invocation count is undefined — lazy means zero runs without readers and multiple runs per change under re-renders/HMR, so analytics under- or over-fire — and getters run during render, so the effect executes on the SSR server and again during client hydration. Effects belong in watch, outside render, with controlled flush timing.

Question

When is a watcher that mutates its own source legal, and when does it die?

Answer

Legal when it converges: the callback's write re-queues the watcher once, the next run finds the invariant satisfied and stops writing (e.g. trimming a list to a max length). It dies with 'Maximum recursive updates exceeded' when each run produces another diverging write (count.value = n * 2). Litmus test: does another run produce another write?

Question

How do you debug a 'Maximum recursive updates exceeded' error whose stack names an innocent component?

Answer

The guard names the detection site, not the author. For every computed and watcher reachable from the render, list what it writes; follow each written piece of state to see whether it reaches a watched source — directly, through a computed, or via a parent-child prop chain. Fix the cycle: derivations become computeds, effects get stabilization conditions or narrower sources.

Revision Notes

Key Takeaways

  • 1. Derivable state is computed's territory: read-only, lazy, cached, chained automatically — watch-maintained copies drift and need manual seeding
  • 2. Effects with no return value — fetches, logs, persistence, DOM measurement — are watch's territory; getters run zero-or-many times and even execute during SSR
  • 3. A computed getter that writes breaks its contract and can loop the render; side effects belong in a watcher with the right flush timing
  • 4. Self-mutating watchers are legal only when they converge within a run or two; diverging ones hit the 100-update 'Maximum recursive updates exceeded' guard
  • 5. Keep the reactive graph a DAG: computeds as pure derivation edges, watches as terminal effects, writes never feeding their own triggers without a stabilization condition

Interview Tips

  • • "computed or watch?" — answer with the test (value vs action), then show you know the failure modes both ways: drifting copies from watch-derivations, zero-or-many invocations and SSR side effects from computed-effects
  • • "Why can't I put the fetch inside a computed?" — async getter caches a Promise, invocation is lazy/undefined, and getters execute during server render; watch with onCleanup + AbortController is the correct shape
  • • "My app throws Maximum recursive updates exceeded — walk me through debugging it" — list writes of every computed/watcher in the path, follow written state back to watched sources including through parent props, distinguish converging self-triggers from diverging loops
  • • "Is a watcher that mutates its own source always wrong?" — no: converging ones are documented and fine; give the list-trimming example and the n*2 counter-example to show both sides

Cheat Sheet

computed vs watch — The Decision

The test: value to render → computed · action with no return value → watch.

computed watch
Purpose derive a value run an effect
Execution lazy, cached, once per change per cache eager, per source change (batched per flush)
Writes forbidden (readonly warning) the callback's whole job
Timing control none needed immediate / deep / flush / once
History none new + old values
During SSR runs at render — values only never during render — effects safely deferred

Derivation via watch — the four defects: mutable copy that drifts · hand-wired dependency chains · recomputes with zero readers · needs immediate-seeding.

Effects that must be watch: fetches (with onCleanup AbortController) · debounced search (cleanup clears the timer) · localStorage/analytics · DOM measurement (flush: 'post') · websocket sends.

Loops — 'Maximum recursive updates exceeded':

// diverging (dies):                 // converging (legal):
watch(count, n => count.value = n*2) watch(list, l => { if (l.length > 10) l.splice(0, l.length-10) })
computed(() => { count.value++; ... })   // getters write NOTHING — always broken

Trace method: list what each computed/watcher writes → follow writes back to watched sources (incl. through props) → break the cycle.

Target shape: state → computeds (pure edges) → template, with watches as terminal effects writing only into state they don't watch.

Continue Learning