Vue – Watchers: watch and watchEffect

October 27, 20255 min readUpdated 8/24/2026

A computed derives a value. A watcher runs a side effect when something changes — fetch this, play that, start a timer, write to storage. If you want a computed that does something rather than returns something, you want a watcher.

watch

The search page is the clearest example in the demo application, because the watcher is the whole architecture of the page:

watch(
  () => route.query,
  () => {
    term.value = route.query.q ?? "";
    load();
  },
  { immediate: true, deep: true }
);

Read that with the comment above it in the source: the query lives in the URL rather than in component state. Everything that changes a filter pushes a route; the route change triggers the fetch. There is exactly one path to loading results, so a search cannot get out of step with the address bar — which is what makes a result page shareable and the back button behave.

The source argument

watch takes what to watch, then what to do. The source can be:

watch(term, (value) => { /* a ref */ });
watch(() => route.query, () => { /* a getter */ });
watch(() => props.active, (isActive) => { /* a getter over a prop */ });
watch([term, page], ([t, p]) => { /* several at once */ });

A getter is required for anything that is not itself a ref. watch(route.query, ...) passes the current value — a plain object — and watches nothing. watch(() => route.query, ...) passes a function Vue can re-run to check. This is the most common mistake with watchers and it fails silently: the callback simply never fires.

immediate

By default a watcher runs on change, not on setup. On a page whose watcher is the only thing that loads data, nothing loads until something changes — the page renders empty forever.

{ immediate: true } runs the callback once straight away, which is why Explore uses it. It is the difference between a watcher that reacts to changes and one that owns a value's whole lifecycle.

deep

{ deep: true } watches nested mutations, not just replacement of the top-level value. Explore needs it because route.query is an object whose properties change.

It is not free — a deep watcher traverses the whole structure on every check, so pointing one at a large array of objects has a real cost. Prefer a specific getter, () => route.query.q, when you only care about one field.

A watcher owning an element

The feed's player is the other pattern worth studying: a watcher keeping a piece of the DOM in step with a prop.

watch(
  () => props.active,
  (isActive) => {
    const el = videoEl.value;
    if (!el) {
      // No <video> at all (poster-only reel): the view still counts, because the
      // user did look at it.
      if (isActive) countView();
      return;
    }
    if (isActive) {
      // play() rejects if the browser blocks autoplay. Muted autoplay is allowed
      // everywhere, which is why `muted` defaults to true - but a user who
      // unmutes and then scrolls can still hit the block, so swallow it rather
      // than letting an unhandled rejection surface.
      el.play().then(() => (isPlaying.value = true)).catch(() => (isPlaying.value = false));
      countView();
    } else {
      el.pause();
      el.currentTime = 0;
      progress.value = 0;
      isPlaying.value = false;
    }
  },
  { immediate: true }
);

Three things there are worth stealing.

immediate: true handles the mount case. A slide already active when the component appears has to start playing; without this it would sit still until the prop changed, which for the first slide is never.

The null check comes first. A watcher can fire before the DOM exists, and a poster-only reel has no <video> at all.

The rejection is swallowed deliberately, with a comment saying why. play() rejects when the browser blocks autoplay. That is expected rather than exceptional, and an unhandled rejection in a watcher is noise in every console.

watchEffect

The other form. Rather than declaring what to watch, you use reactive values and Vue works out the dependencies — the same tracking a computed uses:

watchEffect(() => {
  // Runs immediately, and again whenever `term` or `page` changes. Nothing
  // was declared: reading them inside registered them.
  console.log(`searching ${term.value}, page ${page.value}`);
});

Compared with watch:

It always runs immediately. There is no immediate option because that is the only behaviour.

It does not give you the old value, because it does not know which dependency changed.

Its dependencies are implicit, which is convenient until it is not — a value read inside a conditional branch is only a dependency while that branch runs.

Use watchEffect for a small effect over several values where you do not care which changed. Use watch when you need the previous value, want an explicit dependency list, or do not want it firing on setup. The demo application uses watch ten times and watchEffect never — being explicit ages better in a codebase someone else reads.

Async, and the stale response problem

A watcher that fetches has a race in it. Type "buzz", then "buzzer": two requests are in flight, and if the first returns second the UI shows results for the wrong query.

onCleanup, the third argument to the callback, runs before the next invocation and on unmount:

watch(term, async (value, _old, onCleanup) => {
  const controller = new AbortController();
  // Called when `term` changes again, so the previous request is aborted
  // before this one starts. No stale response can arrive last.
  onCleanup(() => controller.abort());

  const res = await fetch(`/api/reels?q=${value}`, { signal: controller.signal });
  results.value = await res.json();
});

Remember from lesson 5 that tracking stops at the first await. Anything read after it is not a dependency, so read what you need up front.

Debouncing solves the neighbouring problem — not the race, but the volume. Lesson 15 builds the composable the demo application uses for exactly this.

Stopping a watcher

A watcher created in setup stops automatically on unmount, which covers almost every case. One created asynchronously is not tied to the component and must be stopped by hand — watch returns the function that does it:

const stop = watch(term, load);
stop();   // no longer watching

When not to reach for a watcher

The most common misuse is syncing one piece of state into another:

// WRONG -- this is a computed wearing a watcher's clothes.
const fullName = ref("");
watch([first, last], () => {
  fullName.value = `${first.value} ${last.value}`;
});

// RIGHT
const fullName = computed(() => `${first.value} ${last.value}`);

The watcher version is more code, adds a second piece of state that can drift, and does not cache. If your watcher's whole body is an assignment, it should be a computed.

Next: Conditional and List Rendering.