Frontend / Vue / 04_computed_watch_watcheffect.md

computed vs watch vs watchEffect

Updated 5 interview angles 6 min read source
On this page13
  1. When computed vs watchEffect?
  2. When watch vs watchEffect?
  3. When does watchEffect re-run, exactly?
  4. What’s watchPostEffect / watchSyncEffect?
  5. Stopping a watcher.
  6. Watching a deep object.
  7. computed with a setter.
  8. Avoiding infinite loops in watch.
  9. onWatcherCleanup (or the cleanup callback) — when used?
  10. Gotchas
  11. Related
  12. Further reading
  13. Interview angle

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”
ts
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.”

ts
// 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: false is the default for watch; watchEffect always 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.

ts
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.

ts
watchEffect(() => {
  el.value?.focus();
}, { flush: "post" });

Stopping a watcher.

Both watch and watchEffect return a stop function:

ts
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.

Watching a deep object.

ts
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):

ts
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:

ts
// 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:

ts
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

  • computed is cached, so calling it 100 times in a render is free. Subtle: if the getter calls Date.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 unless deep: true. Use a getter watch(() => reactiveObj.field, cb) for specific properties.
  • watchEffect runs immediately; watch does not unless immediate: true. This catches people coming from React’s useEffect (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" (or watchPostEffect).
  • Returning a value from watchEffect is ignored; if you need a value, use computed.

Further reading

Interview angle 5

  • “computed, watch or watchEffect?” - computed for a derived value, because it is cached and lazy and only recomputes when a dependency actually changed. watchEffect for a side effect whose dependencies you want tracked automatically. watch when 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 onCleanup argument or onWatcherCleanup. 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.