Updated Jul 18, 2026

Watchers, Lifecycle, and Fetching

Computed handles "this value follows from that value." But some reactions to change aren't values - they're actions: refetch when the selected id changes, save a draft when the form changes, start a timer when the component appears and stop it when it leaves. Actions in response to change are watchers; actions tied to a component's existence are lifecycle hooks. Together they're Vue's side-effect toolkit - powerful, and the part of Vue most often used where computed should have been.

watch: when this changes, do that

import { ref, watch } from 'vue';

const selectedId = ref(1);
const product = ref(null);

watch(selectedId, async (newId, oldId) => {
  product.value = null;                      // show loading state
  product.value = await fetchProduct(newId);
});

What just happened: watch subscribes to a reactive source (here a ref) and runs the callback on change, with new and old values. The classic use is exactly this: a side effect (network call) in response to a data change (selection).

The source argument has a shape rule that bites everyone once:

watch(selectedId, ...)          // ✓ a ref: watch the container
watch(() => props.userId, ...)  // ✓ a getter: watch an expression/property
watch(props.userId, ...)        // ✗ passes today's VALUE (a number) - nothing to subscribe to

Passing props.userId evaluates immediately to a plain number - dead, per phase 3, and Vue warns about an invalid watch source. Anything that isn't itself a ref/reactive object gets wrapped in a getter function.

Two options cover most real needs:

watch(source, callback, { immediate: true });  // also run once right now (initial fetch + refetch in one)
watch(form, callback, { deep: true });         // fire on nested mutations inside an object

deep exists because watching a reactive object or a ref-of-object only fires by default when the identity changes or (for reactive) properties are directly touched by the watcher's tracking - mutating form.address.city deep inside won't trigger a shallow watcher on a ref. deep: true traverses everything (at a cost proportional to the object's size); often the sharper tool is watching the specific field: watch(() => form.address.city, ...).

📝 Terminology: watchEffect(fn) is the auto-tracked sibling: it runs immediately and re-runs whenever anything it read changes - no explicit source list. Convenient for "keep these things in sync" effects; less explicit about when it fires. Reach for watch when you care about which change triggers the action (and want old/new values); watchEffect for fire-and-forget syncing.

Don't watch what you can compute

The most common watcher in beginner Vue is a hand-rolled computed:

// ✗ a second source of truth, updated by machinery
const fullName = ref('');
watch([firstName, lastName], () => {
  fullName.value = `${firstName.value} ${lastName.value}`;
});

// ✓ a derivation
const fullName = computed(() => `${firstName.value} ${lastName.value}`);

💡 Key point: if the reaction to data changing is producing another value, that's computed. If it's doing something - fetching, saving, logging, touching a browser API - that's watch. The computed version can't be stale, can't fire in the wrong order, and deletes three lines.

Lifecycle: onMounted and onUnmounted

A component's script runs before any DOM exists. Code that needs the real page - measuring an element, starting an interval, third-party libraries attaching to a node - waits for mount:

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';

const seconds = ref(0);
let timer;

onMounted(() => {
  timer = setInterval(() => seconds.value++, 1000);
});

onUnmounted(() => {
  clearInterval(timer);   // whatever mount starts, unmount stops
});
</script>

What just happened: onMounted fired after the component's DOM landed on the page; onUnmounted fired when it left (a v-if went false, the route changed). The pairing is the discipline: an interval, listener, or subscription started at mount and not stopped at unmount keeps running against a dead component - the classic leak, stacking a new timer on every remount.

The full hook family exists (onUpdated, onBeforeMount, ...) but these two carry nearly all real usage. If you're reaching for onUpdated, first ask whether a watch on the specific data says what you mean more precisely.

The standard fetch shapes

Initial load - await inside onMounted (or just call an async function from setup):

<script setup>
import { ref, onMounted } from 'vue';
const orders = ref(null);
const error = ref(null);

onMounted(async () => {
  try {
    const res = await fetch('/api/orders');
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    orders.value = await res.json();
  } catch (e) {
    error.value = e;
  }
});
</script>

<template>
  <p v-if="error">Couldn't load orders.</p>
  <p v-else-if="!orders">Loading…</p>
  <ul v-else><li v-for="o in orders" :key="o.id">{{ o.ref }}</li></ul>
</template>

Refetch-on-change - the watch with immediate from above, which handles both first load and every change of the source. And when the same shape repeats across components, phase 5 already showed the endgame: fold it into a useFetch composable.

⚠️ Gotcha: the refetch watcher has the same stale-response race every framework meets: the user flips from product 1 to product 2, responses arrive out of order, and 1 overwrites 2. The watcher callback receives a third argument, onCleanup, made for exactly this:

watch(selectedId, async (id, _, onCleanup) => {
  let cancelled = false;
  onCleanup(() => { cancelled = true; });   // runs before the NEXT callback fires
  const data = await fetchProduct(id);
  if (!cancelled) product.value = data;
});

Recap

  1. watch(source, cb) = side effects on specific changes; sources are refs or getters, never props.x bare.
  2. immediate for run-now-and-on-change; deep (or better: a targeted getter) for nested mutations; watchEffect when auto-tracking reads is clearer.
  3. Producing a value → computed; doing a thing → watch. Never maintain derived state by watcher.
  4. onMounted for DOM-dependent setup; onUnmounted mirrors it - every start gets a stop.
  5. Refetch watchers need onCleanup for the out-of-order response race.
[
  {
    "q": "watch(props.userId, cb) warns about an invalid source and never fires. Why?",
    "choices": [
      "Props can't be watched, only refs can",
      "The expression evaluated to a plain number immediately - watch needs a ref or a getter like () => props.userId",
      "The callback must be async",
      "Watching props requires deep: true"
    ],
    "answer": 1,
    "why": [
      "Props watch fine - through a getter that defers the read.",
      null,
      "Sync callbacks are fine; the source, not the callback, is broken.",
      "deep governs nested objects - a primitive prop needs deferral, not depth."
    ],
    "explain": "Arguments evaluate before the call: props.userId is already just 7 by the time watch sees it. A getter hands watch the recipe instead of the result."
  },
  {
    "q": "A component starts a setInterval in onMounted with no onUnmounted. The component sits behind a v-if that toggles often. What accumulates?",
    "choices": [
      "Nothing - Vue clears intervals when components unmount",
      "A new live interval per mount, each still firing against unmounted component state",
      "Memory only, but the intervals themselves stop",
      "Duplicate DOM nodes"
    ],
    "answer": 1,
    "why": [
      "Vue tears down its own subscriptions - browser timers are yours to clear.",
      null,
      "The intervals keep firing forever - that's the leak's active half, not just retained memory.",
      "The DOM is correctly removed; the timer outliving it is the problem."
    ],
    "explain": "Whatever mount starts, unmount must stop. Each v-if cycle mounts a fresh component that starts a fresh interval; without clearInterval in onUnmounted they all keep running."
  },
  {
    "q": "You need cartTotal to always equal the sum of cart item prices. Which tool?",
    "choices": [
      "A watcher on the cart that updates a cartTotal ref",
      "A computed that reduces over the cart",
      "onUpdated recalculating the total after each render",
      "watchEffect writing into a cartTotal ref"
    ],
    "answer": 1,
    "why": [
      "It works until the day an update path skips it - a maintained copy of derivable data is the anti-pattern this phase names.",
      null,
      "onUpdated fires after every render of anything - maximum wasted work, minimum precision.",
      "Same second-source-of-truth problem in auto-tracking clothes."
    ],
    "explain": "A value that follows from other values is a derivation: computed. Watchers are for actions - fetching, saving, timing - not for maintaining data that computed can express."
  }
]

← Phase 5: Slots and Composition · Guide overview · Phase 7: When Vue Breaks →

Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.

Check your understanding 3 questions

1. watch(props.userId, cb) warns about an invalid source and never fires. Why?

2. A component starts a setInterval in onMounted with no onUnmounted. The component sits behind a v-if that toggles often. What accumulates?

3. You need cartTotal to always equal the sum of cart item prices. Which tool?