computed vs watch vs watchEffect
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.”
When computed vs watchEffect?
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).
When watch vs watchEffect?
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).
When does watchEffect re-run, exactly?
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.
What’s watchPostEffect / watchSyncEffect?
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" });Stopping a watcher.
Both watch and watchEffect return a stop function:
const stop = watchEffect(() => { ... });
stop(); // no more re-runsIn <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.
Watching a deep object.
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.
computed with a setter.
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.
Avoiding infinite loops in watch.
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.
onWatcherCleanup (or the cleanup callback) — when used?
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 Race conditions and AbortController for the underlying pattern.
Gotchas
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.
Related
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
Interview angle 5
- “
computed,watchorwatchEffect?” -computedfor a derived value, because it is cached and lazy and only recomputes when a dependency actually changed.watchEffectfor a side effect whose dependencies you want tracked automatically.watchwhen you need the old value, explicit sources, or the options below. - “You need the DOM updated before your callback runs.” -
flush: "post". The default runs before the DOM patch, so measuring an element, focusing an input or starting an animation reads the pre-update state. This is the ordering question that catches people once each. - “What is wrong with
deep: true?” - it walks the entire object on every change to establish dependencies, so on a large structure it is a real cost paid repeatedly. Watch a getter that returns the specific path you care about instead. - “How do you cancel in-flight async work in a watcher?” - the cleanup
callback, either the
onCleanupargument oronWatcherCleanup. It runs before the next invocation and on teardown, which is the same contract as React’s effect cleanup and solves the same stale-response race. - “Do watchers clean themselves up?” - inside a component, yes, on unmount.
Created outside one - in a store, a composable used at module scope - they
live forever unless you call the returned stop function or wrap them in an
effectScope.