computed vs watch vs watchEffect
TL;DR
computed derives a value from reactive state and caches it until deps change. watch runs a side effect when specific reactive sources change (you list them). watchEffect runs a side effect that automatically tracks what it reads. Rule of thumb: computed for derived values, watch when you need old/new value or fine-grained control, watchEffect when “run this side effect, deps are whatever I read.”
Interview Q&A
Q: When computed vs watchEffect?
A: They look similar but solve different problems:
computed |
watchEffect |
|
|---|---|---|
| Returns | a Ref<T> (value) |
nothing (side effect) |
| Pure function expected? | yes | no |
| Lazy / cached? | yes — recomputed only on access if dirty | no — runs on every dep change |
| Use case | “derived data” | “do something when deps change” |
const items = ref([1, 2, 3]);
// computed — derived value, cached
const sum = computed(() => items.value.reduce((a, b) => a + b, 0));
console.log(sum.value); // 6
// watchEffect — side effect that re-runs on dep changes
watchEffect(() => {
document.title = `Sum: ${sum.value}`;
});
Use computed when downstream code reads a derived value; use watchEffect when something external must happen on change (DOM update, API call, logging).
Q: When watch vs watchEffect?
A: watchEffect is “auto-track whatever I read.” watch is “I’ll tell you exactly what to watch, give me old + new values.”
// watchEffect — implicit deps via reads
watchEffect(() => {
console.log(`count=${count.value}, name=${user.name}`);
});
// watch — explicit source, you get old + new
watch(count, (newVal, oldVal) => {
console.log(`count went ${oldVal} → ${newVal}`);
});
// watch on multiple sources
watch([count, () => user.name], ([newCount, newName], [oldCount, oldName]) => {...});
// watch on a getter (lets you watch a derived value)
watch(() => user.posts.length, (n) => {...});
Use watch when:
- You need the old value (for diff logic, validation, animation, etc).
- You want explicit deps (clearer intent, fewer surprises).
- You want to conditionally fire on the first call (
immediate: falseis the default forwatch;watchEffectalways runs immediately).
Q: When does watchEffect re-run, exactly?
A: Vue runs the effect once immediately (sync), records the reactive deps it read, then re-runs whenever any of those deps change. Deps are re-collected each run, so conditional branches change the dep set.
const a = ref(1);
const b = ref(2);
const useB = ref(false);
watchEffect(() => {
if (useB.value) console.log(b.value);
else console.log(a.value);
});
Initially deps = {useB, a}. After useB.value = true → re-runs, deps now {useB, b}. Subsequent a.value++ no longer triggers.
Q: What’s watchPostEffect / watchSyncEffect?
A: Variants controlling when the effect runs relative to the DOM update:
| When it fires | |
|---|---|
watchEffect (default flush: "pre") |
before component re-renders |
watchPostEffect (flush: "post") |
after DOM has been updated |
watchSyncEffect (flush: "sync") |
synchronously when dep changes (rare; can cause loops) |
Use flush: "post" when you need the DOM to reflect the new state — e.g., reading element dimensions, focusing an input after it appears.
watchEffect(() => {
el.value?.focus();
}, { flush: "post" });
Q: Stopping a watcher.
A: Both watch and watchEffect return a stop function:
const stop = watchEffect(() => { ... });
stop(); // no more re-runs
In <script setup>, watchers are auto-disposed when the component unmounts. In a composable that runs outside a component (rare), you handle disposal explicitly via the returned function or effectScope.
Q: Watching a deep object.
A:
const user = reactive({ name: "Ada", profile: { tier: "pro" } });
// Top-level only by default
watch(() => user, () => console.log("user changed"), { deep: false });
// Deep — fires on any nested mutation
watch(() => user, () => console.log("user changed deeply"), { deep: true });
// Specific nested path — no `deep` needed
watch(() => user.profile.tier, (newTier, oldTier) => {...});
deep: true is expensive on large objects — Vue walks the whole structure to track each property. Prefer watching a specific path.
Q: computed with a setter.
A: Writable computeds for two-way binding (e.g., a full name composed of first + last):
const firstName = ref("Ada");
const lastName = ref("Lovelace");
const fullName = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (v) => {
[firstName.value, lastName.value] = v.split(" ");
},
});
Rare but useful for v-model on derived values.
Q: Avoiding infinite loops in watch.
A: Don’t mutate a reactive value that the watcher itself depends on:
// Infinite loop — modifying the watched value
watch(count, () => { count.value++; });
// Safe — separate state
watch(count, (n) => { lastChanged.value = Date.now(); });
If you must mutate, gate with a flag or use nextTick to defer.
Q: onWatcherCleanup (or the cleanup callback) — when used?
A: When a watcher kicks off async work, the cleanup handler lets you cancel it if the watcher re-fires before completion:
watch(query, async (q, _old, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
const results = await fetch(`/search?q=${q}`, { signal: controller.signal }).then(r => r.json());
results.value = results;
});
Same race-condition fix as React’s useEffect cleanup with AbortController. See ../11_apis_data_fetching/05_abort_and_race_conditions.md for the underlying pattern.
Gotchas / edge cases
computedis cached, so calling it 100 times in a render is free. Subtle: if the getter callsDate.now()or another non-reactive impurity, the cache lies. Keep computeds pure.watch(reactiveObj, cb)watches by reference identity — won’t fire on nested changes unlessdeep: true. Use a getterwatch(() => reactiveObj.field, cb)for specific properties.watchEffectruns immediately;watchdoes not unlessimmediate: true. This catches people coming from React’suseEffect(which always runs on mount).- Watchers run after the change, not before. To intercept (cancel/validate), use a separate API or controlled
v-model. - Effects flush order matters with DOM measurement — use
flush: "post"(orwatchPostEffect). - Returning a value from
watchEffectis ignored; if you need a value, usecomputed.
What a senior is expected to say
- “
computedfor derived values — cached and lazy.watchEffectfor side effects with auto-tracked deps.watchwhen I need old value, explicit sources, or fine-grained options.” - “
flush: 'post'to run after DOM update — focus, measurement, animation.” - “
deep: trueis a perf footgun on large objects; prefer watching a specific path via a getter.” - “Cleanup callback (
onCleanuparg oronWatcherCleanup) is the abort hook for async work — same idea as React’suseEffectcleanup.” - “Watchers auto-dispose with the component unless created outside one — then use
effectScopeor the returned stop function.”
Cross-references
- Reactivity primitives behind these: 02_reactivity_internals.md
refvsreactivedeciding what to watch: 03_ref_vs_reactive.md- AbortController + race conditions (same pattern in React): ../11_apis_data_fetching/05_abort_and_race_conditions.md
Further reading
- Vue docs — Computed: https://vuejs.org/guide/essentials/computed.html
- Vue docs — Watchers: https://vuejs.org/guide/essentials/watchers.html
- Vue docs — Reactivity API: Core (full
watch/watchEffectoptions): https://vuejs.org/api/reactivity-core.html