Run First, Track Synchronously: The watchEffect Execution Model
watch makes you declare what you depend on. watchEffect inverts that: you write one function, it runs, and every reactive property the function reads becomes a dependency — discovered by execution, not by declaration.
watchEffect(() => {
document.title = `${route.path} — ${appName}`
})
At setup() time this function executes immediately — that first run is also the declaration step. Reading route.path and appName registers the effect as a subscriber of both. From then on, navigating or an app-name change re-runs the whole function with the fresh values. There is no source argument, no immediate: true (it is the only behavior), and no callback — the effect body is the callback.
The execution model has four precise properties, and knowing them separates correct use from mysterious re-runs:
1. The tracking window is the synchronous run. Dependencies are collected while the function body executes, up to its first await or its return. Only reads inside that window register.
2. Dependencies are re-collected on every run. The effect starts each run with an empty dependency set and rebuilds it from what this run actually read:
watchEffect(() => {
console.log('active tab:', activeTab.value) // always tracked
if (activeTab.value === 'audit') {
console.log(auditCursor.value) // tracked only on runs where the branch executes
}
})
While activeTab is 'home', auditCursor is not a dependency — changing it does nothing. The moment activeTab becomes 'audit' (which is tracked), the effect re-runs and picks auditCursor up. Branch-taken dependencies are live; branch-not-taken ones are invisible until something else re-runs the effect. This is usually correct — the effect needs auditCursor only when rendering audit UI — but it surprises people who expect the whole body to be watched.
3. One dependency change re-runs the entire body. There is no memoized sub-part. A change to either route.path or appName re-executes the whole title effect. For cheap synchronous work that is the right trade; for heavy derivations, a computed recomputes only what its own cache demands.
4. Lifetime matches watch. The returned handle stops it, and an effect created in setup() stops on unmount — the same scope rules as any watcher.
Scheduling is also shared: watchEffect defaults to flush: 'pre' (runs before the component re-renders), accepts flush: 'post' for DOM-measuring effects, and once: true to fire a single time. The most-used effect in real codebases is also the smallest — the debugging probe:
watchEffect(() => console.log('cart total:', cartTotal.value))
It prints immediately and again on every change, which turns "why isn't this updating?" into a visible dependency question in seconds.
Two debugging tools finish the execution model. The effect accepts options as a second argument, including the dev-only onTrack/onTrigger hooks — the fastest way to answer "why did this re-run?":
watchEffect(() => {
syncTitle(route.path, appName)
}, {
onTrigger(e) { debugger } // pauses on the mutation that re-runs the effect (dev only)
})
And because tracking is by read, the effect composes through other reactive wrappers: reading a computed tracks that computed's own dependencies transitively, reading an injected ref (inject(key)) subscribes to the provider's source, and reading a prop subscribes to the parent's updates. The one scheduling case where the immediate first run is not enough is DOM measurement — a synchronous first run sees pre-patch layout — so pair flush: 'post' with onMounted to cover the initial paint:
onMounted(() => {
watchEffect(() => { height.value = el.value.offsetHeight }, { flush: 'post' })
})
The scheduling surface — flush options, lifetime rules — is shared with Watching Sources with watch; the APIs differ only in where dependencies come from.
A fair way to frame the relationship in an interview: watchEffect(fn) behaves like watch(fn, fn, { immediate: true }) — immediate invocation plus automatic sources — minus the old-value argument and minus the ability to declare the source separately. That subtraction is exactly when to stop using it; the cleanup chapter shows the async case, and the next chapter's decision table shows the rest.
Cancelling Stale Work: onCleanup and the Search-as-you-type Race
An effect that fetches data re-runs whenever its dependencies change — which means several runs of the effect can be in flight at once. Network latency is uneven, and responses arrive in whatever order the network chooses. Without cancellation, a slow response for an old query overwrites the fast response for the current one, and the UI tells a lie that no amount of correct rendering can fix.
watchEffect's answer is the onCleanup API: the effect function may call onCleanup(fn), and Vue invokes fn before every re-run of the effect and once more when the effect stops (component unmount or explicit stop). The canonical search-as-you-type implementation:
const query = ref('')
const results = ref([])
watchEffect(async () => {
const controller = new AbortController()
onCleanup(() => controller.abort()) // cancel THIS run's request
const q = query.value // read before any await — tracked
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
signal: controller.signal
})
const data = await res.json()
results.value = data.hits // only the surviving run writes
})
Trace two rapid keystrokes, v then vue. Run A starts and begins fetching v. The vue mutation re-runs the effect: cleanup fires first, aborting run A's fetch; run B starts its own controller and fetch. When the aborted fetch rejects with AbortError, run A's continuation dies — its results.value = line never executes — and run B's response lands alone. The cleanup protocol makes the latest invocation the only writer.
The pattern generalizes beyond fetch. Any per-run resource gets torn down in the same place it was created:
watchEffect(() => {
const timer = setTimeout(() => poll(), 30_000)
onCleanup(() => clearTimeout(timer))
const stop = store.subscribe(handleChange)
onCleanup(stop)
window.addEventListener('resize', onResize)
onCleanup(() => window.removeEventListener('resize', onResize))
})
Each re-run tears down the previous run's timer, subscription, or listener before creating new ones — otherwise a component that re-runs its effect a hundred times stacks a hundred listeners and multiplies every event by a hundred. This is also why cleanup is a memory-leak fix, not only an async-race fix.
Two usage rules keep it honest:
- Create and register inside the same run. Cleanup functions close over the resources of the run that registered them; hoisting the resource outside the effect means cleanup cancels the wrong instance.
- Cleanup runs on unmount too. A long-lived
setIntervalregistered with cleanup is torn down at component teardown with no manualonUnmountedbookkeeping — the single biggest simplification cleanup brings over the pre-3.0mounted/unmountedpairing.
A third rule and a new API complete the async story. Catch your own abort, not the user's error. An aborted fetch rejects with AbortError, and if that rejection reaches an error boundary — or a catch that sets error.value — a routine keystroke shows the user "request failed". Treat the signal's own rejection as control flow:
let data
try {
const res = await fetch(url, { signal: controller.signal })
data = await res.json()
} catch (e) {
if (e.name === 'AbortError') return // superseded by a newer run — not a failure
throw e // real network error: surface it
}
For cleanup attached to a specific watcher from outside its own callback — a shared resource a composable tears down against an explicit handle — Vue 3.5 added onWatcherCleanup(cleanup, watcher), callable synchronously while that watcher is executing. Inside a watchEffect or watch callback, plain onCleanup remains the idiomatic form; the new API exists for composables that manage watchers created elsewhere.
The same protocol exists in watch's callback as its third argument — identical semantics, one difference in trigger count: a watch callback runs only when its declared source changes, while the watchEffect body re-runs whenever anything it read changes, including state it read only to log. Effects that fetch on one key but read five others for formatting will re-fetch five times too often; declaring the source explicitly with watch is the cure, which leads directly into the decision between the two APIs.
When Tracking Betrays You: Four Situations Where watch Wins
Automatic tracking is a superpower with a hard boundary: the effect tracks what it reads, and only that. Reads outside the synchronous window — after an await, inside a callback that fires later, inside a setTimeout — register with nothing. The effect believes it has no dependencies, runs once, and goes silent forever.
// BROKEN — the read happens after await, outside the tracking window
watchEffect(async () => {
const user = await fetchUser()
const team = await fetchTeam(user.teamId) // user.teamId read post-await: UNTRACKED
detail.value = team
})
Change user's team and nothing happens — the effect's dependency set never contained user.teamId. The repairs, in order of preference: read every dependency before the first await and thread the values through (the cleanup chapter's pattern); restructure so the fetched data lands in refs the effect reads synchronously; or declare the source with watch(() => user.teamId, ...), whose getter is re-invoked as a proper dependency even across async work.
Conditional branches — the other tracking subtlety from the mechanics chapter — behave differently and are worth stating exactly: a read inside if (cond.value) {...} registers only on runs where the branch executes. That is not a bug: when cond flips (it is tracked), the effect re-runs and discovers the inner dependency. The genuine hazard is a dependency read only inside a callback the effect merely registers rather than calls:
// BROKEN — item.id is read later, inside the handler, not during the run
watchEffect(() => {
button.addEventListener('click', () => track(item.id))
})
The run reads button and item's existence — but item.id's value is only touched when a human clicks, outside any tracking window.
Beyond tracking, three more situations make watch the better tool:
1. You need the old value. watchEffect has no oldValue — its only signature is the effect function. Comparing before/after ("did the status actually change?", diffing for a logger) requires watch's callback arguments.
2. You need to control when it fires. The effect re-runs on any read dependency, so "fetch on id, format with locale, but only refetch on id" is impossible to express — the effect re-fetches on locale changes too. watch(() => id.value, fetch, ...) draws the line precisely: locale can be read inside the fetch body without becoming a trigger.
3. You need deep watching, multiple deliberate sources, or payload validation. watch([a, () => b.value], ([na, nb], [oa, ob]) => ...) with an old-value-bearing callback and coalescing semantics is unavailable to an effect that simply executes.
One failure mode is loud enough to deserve its own warning: an effect that writes what it reads. watchEffect(() => count.value++) reads count — tracking it — and writes it — re-triggering itself, forever. Vue detects the runaway and reports it:
Maximum recursive updates exceeded in <App>
The same loop appears indirectly: an effect that writes a ref its own computed dependencies flow from, or one that pushes into the array it also iterates. The fix is structural — the effect's writes must not feed its reads. When they genuinely must overlap, declare the source with watch so only the intended half of the cycle triggers, or guard the write to fire only on real change (if (next !== current)), which lets the recursion stabilize after one extra pass instead of never.
The interview-sized summary: computed derives a cached value, watchEffect reacts to everything it touches, watch reacts to a declared source with history and timing control. Reach for the effect when the dependency set is genuinely "whatever I read" — syncing the document title, logging state, wiring subscriptions with cleanup — and step up to watch the moment precision about what triggers the work, or knowledge of the previous state, becomes part of the job. The computed-versus-watch decision completes the three-way choice.
Quiz
1. When does a watchEffect function first execute, and when are its dependencies registered?
2. watchEffect(async () => { const u = await fetchUser(); detail.value = u.teamId }) — why does changing the user's team not update detail?
3. When exactly do the functions registered with onCleanup inside a watchEffect run?
4. You need an effect that fetches when route.params.id changes, but must NOT refetch when a locale ref it also reads changes. What do you do?
Flashcards
Question
How does watchEffect discover dependencies, and when does it first run?
Click to reveal answer
Answer
It runs its function immediately at setup, and every reactive property read during that synchronous execution is registered as a dependency. Each re-run re-collects the dependency set from scratch, so reads in unexecuted branches are not tracked until a run takes that branch.
Question
Why are reads after an await invisible to watchEffect, and what is the fix?
Click to reveal answer
Answer
The tracking window is the synchronous portion of the run; post-await reads happen outside it and register nothing, so the effect never re-runs for them. Fix: read all dependencies before the first await and thread the values through the async work, or declare the source explicitly with watch(() => expr, cb).
Question
What does onCleanup do inside a watchEffect, and what bug does it fix?
Click to reveal answer
Answer
Registered cleanups run before every re-run and once on stop (unmount or handle()). It fixes stale-work races (AbortController so only the latest fetch writes state) and resource stacking (clearTimeout, unsubscribe, removeEventListener torn down before the next run creates new ones).
Question
Four situations where watch beats watchEffect.
Click to reveal answer
Answer
1) You need oldValue for diffs or comparisons. 2) The trigger set must be precise — re-run on id but not on formatting deps the body reads. 3) You need declared sources, deep-watching control, or array sources. 4) Dependencies are read conditionally, in callbacks, or after awaits — declared sources track reliably where automatic tracking can't see.
Question
What options does watchEffect share with watch, and what API does it lack?
Click to reveal answer
Answer
Shared: flush ('pre' default, 'post' for DOM work), once: true, returned stop handle, scope-based auto-stop on unmount. Lacking: a source argument and callback arguments — there is no oldValue, no per-source declaration, and no deep option; the body itself is the entire API.
Revision Notes
Key Takeaways
- 1. watchEffect runs immediately and tracks every reactive read in its synchronous execution; dependencies are re-collected on each run
- 2. Only the synchronous window registers dependencies — post-await reads and reads inside later callbacks are invisible, and watch with a declared source is the repair
- 3. onCleanup runs before every re-run and on stop, which is both the race-condition guard (AbortController) and the listener/timer teardown mechanism
- 4. watchEffect re-runs the entire body on any dependency change, so precise trigger sets, old values, and deep control all point to watch
- 5. flush 'pre'/'post' and once work identically in both APIs; scope-based auto-stop is the shared lifetime rule
Interview Tips
- • "Difference between watch and watchEffect?" — watchEffect runs immediately with automatic dependency discovery and no oldValue; watch declares its source, gets (new, old, onCleanup), and controls timing — then give one concrete case for each
- • "Why doesn't my watchEffect re-run?" — the dependency is read after an await or inside a handler, outside the tracking window; show the pre-await read pattern as the fix
- • "Stop a race in a search effect" — new AbortController per run, abort registered with onCleanup, so cleanup before the next run cancels the stale request and only the latest invocation writes
- • "How do you tear down subscriptions in an effect?" — register the unsubscribe function with onCleanup in the same run that created it; it fires on re-run and on unmount, replacing manual onUnmounted bookkeeping
Cheat Sheet
watchEffect
const stop = watchEffect((onCleanup) => {
const q = query.value // read SYNCHRONOUSLY → tracked
const controller = new AbortController()
onCleanup(() => controller.abort()) // runs before next re-run + on stop
load(q, controller.signal)
}) // flush: 'pre' default · 'post' · once: true · auto-stops with setup scope
Execution model: runs immediately at setup · deps = reactive reads during the synchronous run · re-collected every run · one dep change re-runs the whole body.
Tracking boundary:
| Read happens | Tracked? |
|---|---|
| synchronous body | yes |
inside if branch that executed |
yes (until a run skips the branch) |
after await |
no — thread pre-await values through |
| inside a registered callback/handler | no — read before registering |
Cleanup = cancel before re-run + teardown on stop: AbortController (search races) · clearTimeout · unsubscribe · removeEventListener. Create and register inside the same run.
Choose watch instead when: you need oldValue · the trigger set must exclude values the body reads (locale formatting, etc.) · you want declared/array sources and deep control · deps are read post-await or in callbacks.
Probe: watchEffect(() => console.log('total:', total.value)) — prints now and on every change; fastest dependency debugger in the toolbox.