Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

Vue watch: When to Use It, and the Three Times You Should Not

Sean

Platform Writer

Aug 20, 2026
8 min read

watch takes a reactive source and a callback, and runs the callback when the source changes. It is for side effects — fetching, logging, syncing to storage. If your watcher only sets another piece of state, it should have been a computed property.

Vue watch: When to Use It, and the Three Times You Should Not

The reactivity system is the best part of Vue and the watcher is its sharpest edge. Most watchers people write are correct; a meaningful minority are computed properties in disguise, and those are the ones that produce stale values and hard-to-trace update loops.

This covers the mechanics, the source types that catch people out, and the cases where the answer is a different tool.

Table of contents

Sources: the part that silently does nothing

In the Composition API, watch accepts several kinds of source, and passing the wrong shape fails quietly.

import { ref, reactive, watch } from 'vue';

const count = ref(0);
const state = reactive({ user: { name: 'Sam' } });

// A ref -- pass it directly
watch(count, (now, before) => {
  console.log(`${before} -> ${now}`);
});

// A reactive object property -- must be a getter
watch(() => state.user.name, (now) => {
  console.log('name is now', now);
});

// Several sources at once
watch([count, () => state.user.name], ([c, n]) => {
  console.log(c, n);
});

The second case is the trap. Writing watch(state.user.name, cb) passes the current string value, not a reactive source. Vue has nothing to track, the watcher never fires, and there is no warning. The documentation is explicit that you must use a getter here, and it is still the most common mistake with the API.

The rule is short enough to memorise: a ref can be passed directly; anything reached through a property access must be wrapped in an arrow function.

Deep watching, and why it is rarely what you want

Watching an object watches the reference, not the contents. Mutating a nested property does not trigger it:

watch(() => state.user, (now) => {
  // fires only when state.user is REPLACED
});

watch(() => state.user, (now) => {
  // fires on any nested change
}, { deep: true });

Deep watching traverses the entire object on every change to check what moved. On a large or deeply nested structure that is real work, repeated frequently, and it is a common cause of unexplained sluggishness in a Vue application.

There is also a wrinkle that trips people up: with a deep watcher, the old and new values are the same object reference, because the object was mutated in place rather than replaced. Comparing them tells you nothing.

The better instinct is to watch the specific thing you care about:

// Instead of deep-watching the whole form
watch(() => state.form, save, { deep: true });

// Watch the fields that actually matter
watch(
  () => [state.form.email, state.form.plan],
  save
);

Narrower, faster, and it tells the next reader which fields the side effect actually depends on.

Note also that watch on a reactive object is implicitly deep, which surprises people who did not ask for it. A getter returning that object is not — the distinction is easy to trip over.

Options that matter: immediate, once, and flush

By default a watcher does not run until the source changes, which means the initial state is never handled. That is why so many components have a fetch call duplicated between onMounted and a watcher.

// The duplication
onMounted(() => fetchUser(props.id));
watch(() => props.id, fetchUser);

// One watcher, running eagerly
watch(() => props.id, fetchUser, { immediate: true });

{ once: true } stops after the first fire, which is occasionally exactly right and usually a hint that you wanted onMounted.

flush decides when the callback runs relative to rendering. The default is 'pre' — before the DOM updates — so a watcher reading element.offsetHeight sees the previous layout. If your callback measures or manipulates the DOM, you want 'post':

watch(items, () => {
  container.value.scrollTop = container.value.scrollHeight;
}, { flush: 'post' });

This is the answer to a whole family of why is my measurement one render behind bugs.

And clean up after yourself. onWatcherCleanup runs before the next invocation and on unmount, which is how you cancel a request that is about to be superseded:

watch(() => props.query, async (q) => {
  const controller = new AbortController();
  onWatcherCleanup(() => controller.abort());

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

Without that abort, a fast typist generates overlapping requests and the results are applied in whatever order they return — which is how a search box ends up showing results for a query the user has already replaced.

Three times you should not use a watcher

1. Deriving one value from another. This is the big one.

// Wrong -- a watcher maintaining derived state
const items = ref([]);
const total = ref(0);
watch(items, (list) => {
  total.value = list.reduce((sum, i) => sum + i.price, 0);
}, { deep: true });

// Right -- computed
const total = computed(() =>
  items.value.reduce((sum, i) => sum + i.price, 0)
);

The computed version is cached, cannot go stale, has no ordering issues, and removes a piece of state that could disagree with its source. The test is simple: if the callback body is only an assignment to other reactive state, it is a computed property.

2. Reacting to an event. If a value changes because the user clicked something, handle it in the click handler. Routing the intent through state and then watching that state hides the causal chain — the next reader has to work out what set the value and why.

3. Watching props to copy them into local state. This creates two sources of truth that drift. Use a computed, or defineModel for two-way binding, and keep one authoritative value.

What watchers are genuinely for: fetching when an identifier changes, persisting to local storage, imperatively driving a non-Vue library, logging, and starting or stopping timers and subscriptions. All side effects, all things that reach outside the reactive system.

How this fits the rest of the stack

The heuristic worth keeping is that computed describes what a value is, and watch describes what should happen. Most reactivity bugs are one being used for the other.

The watchers that survive that test are usually doing I/O — fetching, persisting, subscribing — which means they depend on an API that is up, fast, and consistent between environments. On RunxBuild, the backend that serves those requests deploys from your GitHub repository as a service in Node, Next.js, Python, Go, Ruby, Java, .NET or Docker, with build and runtime logs in one place so a request your watcher fired is visible on the server side, environment variables per service, and rollback to the previous deploy. The frontend can ship as a static site from the same repository with 120GB of bandwidth included, then $0.10/GB. To see what the site, the service and a managed database come to together, the RunxBuild hosting calculator lists them as separate line items.

Useful related references:

FAQ

Why is my Vue watcher not firing?

Almost always because the source is not reactive. Passing state.user.name hands over the current value, not something Vue can track — you must wrap property access in a getter: watch(() => state.user.name, cb). Refs can be passed directly. There is no warning when you get this wrong, which is why it is the most common mistake with the API.

When should I use watch instead of computed?

Use computed when you are deriving a value from other state, and watch when a change should cause a side effect — fetching, persisting, logging, or calling into a non-Vue library. If the watcher’s body only assigns to other reactive state, it should have been a computed property.

What does deep: true actually do?

It makes Vue traverse the entire object on every change so nested mutations trigger the callback. That traversal is real work on large structures and is a common cause of sluggishness. It also means the old and new values are the same reference, since the object was mutated rather than replaced, so comparing them tells you nothing.

How do I run a watcher immediately on mount?

Pass { immediate: true }. Without it the callback only runs on subsequent changes, which is why so many components duplicate a fetch between onMounted and a watcher. One watcher with the immediate option replaces both.

How do I cancel a request when the watched value changes again?

Register a cleanup with onWatcherCleanup inside the callback and abort the request there. It runs before the next invocation and on unmount. Without it, rapid changes produce overlapping requests whose results are applied in arrival order, so the displayed data can belong to a superseded query.

#vue watch#vue#composition api#reactivity#javascript