frontend / vue / 04_computed_watch_watcheffect.md

computed vs watch vs watchEffect

5 min read source

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

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

What a senior is expected to say

  • computed for derived values — cached and lazy. watchEffect for side effects with auto-tracked deps. watch when I need old value, explicit sources, or fine-grained options.”
  • flush: 'post' to run after DOM update — focus, measurement, animation.”
  • deep: true is a perf footgun on large objects; prefer watching a specific path via a getter.”
  • “Cleanup callback (onCleanup arg or onWatcherCleanup) is the abort hook for async work — same idea as React’s useEffect cleanup.”
  • “Watchers auto-dispose with the component unless created outside one — then use effectScope or the returned stop function.”

Cross-references

Further reading