← blog
VueOctober 12, 2026 · 15 min read

Vue watch vs watchEffect: The Complete Guide

Vue's watch() and watchEffect() look interchangeable but track dependencies, run timing, and cleanup differently. The exact rules, verified against Vue 3.5.

Parsa Jiravand · Frontend engineer · building bestpractic
Vue watch vs watchEffect: The Complete Guide

You type "vue" into a search box, one letter at a time. Four requests go out — for v, vu, vue, and the deleted-and-retyped vue again — and the one for v happens to come back last. The screen ends up showing results for a single letter, on top of a watch() callback that looks completely correct. Nothing in that callback is wrong. The watcher just never learned that a newer run had already made it obsolete.

This is the article that fixes that, and along the way answers the question every Vue developer eventually asks: when do I reach for watch(), when do I reach for watchEffect(), and why does my callback keep firing at the wrong moment?

By the end of this article you'll be able to:

  • Explain the real difference between watch() and watchEffect() — not "one is explicit, one is automatic," but what that difference costs you
  • Pick the correct flush timing (pre, post, sync) for a watcher that needs to read the DOM
  • Use onWatcherCleanup to cancel a stale async response before it overwrites a newer one
  • Stop a watcher manually, and know when Vue stops it for you
  • Recognize the specific ways both APIs quietly track (or fail to track) the wrong things

You should already be comfortable with ref(), reactive(), and computed() in the Composition API. If "why does destructuring a reactive() object lose reactivity" isn't yet second nature, read Vue Reactivity Explained: ref vs reactive first — this article assumes that mental model and builds on it.

This is written against Vue 3.5.x (verified September 2026). Version-specific notes (the once option, onWatcherCleanup) are called out where they apply.

Here's the search box, written the way it's tempting to write it the first time:

Vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup> import { ref, watch } from "vue"; const query = ref(""); const results = ref([]); watch(query, async (q) => { if (!q) { results.value = []; return; } const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`); results.value = await res.json(); }); </script>

Every keystroke fires a new request, and requests do not resolve in the order they were sent. If the request for v is slow and the request for vue is fast, vue's results land first and v's results land after, silently overwriting them. The callback did exactly what it was written to do; it just never asked "am I still the most recent run?"

That question is exactly what a watcher's cleanup mechanism answers. Getting there first requires being precise about what watch() and watchEffect() actually are.

The mental model: watch() is told what to watch. watchEffect() discovers what to watch by running once and recording every reactive value it reads. Everything else — old/new values, laziness, immediacy — falls out of that one distinction.

  • watch(source, callback) takes an explicit source (a ref, a getter, a reactive() object, or an array of them) and a callback. It does not run on creation by default. It runs only when the named source changes, and it hands the callback both the new and the old value.
  • watchEffect(fn) takes a single function with no separate source. It runs immediately, once, synchronously (well — see flush timing below) to figure out what it read, and re-runs itself in full whenever any of those dependencies change. There is no "old value" concept, because there's no fixed source to diff against — the whole function reruns.

Put differently: watch is a subscription to a specific address. watchEffect is a subscription to whatever addresses this function happens to visit today — and it can visit different addresses on different runs, exactly like a computed() getter does. The dependency-tracking mechanism (a run records every reactive value it reads) is shared across computed, watch's getter-form sources, and watchEffect alike.

TypeScript
1
2
3
4
5
6
7
8
9
import { ref, watch } from "vue"; const count = ref(0); watch(count, (newVal, oldVal) => { console.log(`count went from ${oldVal} to ${newVal}`); }); count.value++; // logs: "count went from 0 to 1"

Key concept: the callback did not run when watch was declared — only after count changed. That's the "lazy by default" behavior, and it's the first thing that trips people up coming from watchEffect.

TypeScript
1
2
3
4
5
6
7
8
9
10
import { ref, watchEffect } from "vue"; const count = ref(0); watchEffect(() => { console.log(`count is now ${count.value}`); }); // logs immediately: "count is now 0" count.value++; // logs: "count is now 1"

Key concept: watchEffect ran once immediately, on creation, before anything changed — because it has to run once just to discover that count is a dependency. watch never needed to run to know its source; you named it.

If the job is "sync this state to something external, right now and on every future change" — a document title, a localStorage key, an analytics call — watchEffect is usually the shorter answer. Need the old value, or need to stay silent until one specific thing changes? Reach for watch.

watch accepts an array of sources, and hands the callback matching arrays of new/old values:

TypeScript
1
2
3
watch([firstName, lastName], ([newFirst, newLast], [oldFirst, oldLast]) => { console.log(`${oldFirst} ${oldLast} -> ${newFirst} ${newLast}`); });

Watching a single property of a reactive() object requires a getter function, not the bare property — because reactive().someProp is just a plain value the instant you read it out of the object, with no reference back to the proxy:

TypeScript
1
2
3
4
5
6
7
8
9
10
const state = reactive({ count: 0, name: "vue" }); // Wrong: this is a snapshot number, not a reactive source. // watch(state.count, cb) never fires. // Correct: a getter re-reads the proxy every time Vue checks it. watch( () => state.count, (newCount, oldCount) => console.log(newCount, oldCount) );

This is the same "reactivity is a subscription made at read time" rule from the ref vs reactive article — watch needs something it can re-read, and a getter is how you hand it that.

Three options on watch change when and how often it fires (watchEffect accepts only flush, covered next, plus the dev-only onTrack/onTrigger — it runs on creation and tracks everything it reads, so immediate, deep and once don't apply):

TypeScript
1
2
3
4
5
watch(source, callback, { immediate: true, // also run once, right away, with oldValue undefined deep: true, // walk the whole object/array, not just the top-level reference once: true, // (Vue 3.4+) auto-stop after the callback fires the first time });

deep: true is the one to use sparingly. Watching a getter that returns an object (() => state.someObject) or a ref holding one only fires when the whole object is replaced (a reactive() object passed directly is already implicitly deep); deep walks every nested property so a change three levels down also fires — at the cost of Vue actually traversing the structure to set that tracking up. On a large or deeply nested object, that traversal is real work.

Both watch and watchEffect accept flush, and it answers a different question than the options above: relative to a component's own re-render, when does this callback run?

flush valueWhen it runsDefault?
'pre'Before the component re-rendersYes, for both APIs
'post'After the component has re-rendered and the DOM is updatedNo
'sync'Synchronously, the instant the dependency is writtenNo

This connects directly to the batching behavior from Vue nextTick Explained: a pre-flush watcher (the default) can run before the DOM reflects the change that triggered it. If your callback needs to measure or read an element that just changed size or content, flush: 'pre' will read stale layout:

TypeScript
1
2
3
4
5
6
7
8
9
watch( items, () => { // With the default 'pre' flush, the DOM here may not yet // reflect the new `items` — this measurement can be one step behind. console.log(listRef.value.scrollHeight); }, { flush: "post" } // wait for the DOM update first );

flush: 'sync' skips Vue's batching queue entirely and runs the instant the dependency is written — useful for invalidating an external cache the moment something changes, but dangerous on sources mutated many times synchronously (e.g. an array filled in a loop), since it fires on every single mutation with no batching.

Back to the search box. The fix is to give each run a way to say "abort me if a newer run has already started" — and both watch and watchEffect give you exactly that, as onWatcherCleanup() (3.5+), which must be called synchronously during the callback (before any await):

Vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<script setup> import { ref, watch, onWatcherCleanup } from "vue"; // onWatcherCleanup: Vue 3.5+ const query = ref(""); const results = ref([]); watch(query, async (q) => { if (!q) { results.value = []; return; } const controller = new AbortController(); onWatcherCleanup(() => controller.abort()); try { const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, { signal: controller.signal, }); results.value = await res.json(); } catch (e) { if (e.name !== "AbortError") throw e; // an aborted run is expected, not an error } }); </script>

Key concept: onWatcherCleanup registers a function that Vue calls right before the next run of this same watcher — and also when the component unmounts. Type vue, and the moment the e keystroke starts a new run, the previous run's cleanup fires first, aborting its in-flight fetch. An aborted request rejects with an AbortError instead of resolving, so it can never reach results.value — and the try/catch swallows just that expected rejection, which would otherwise surface through Vue's error handler on every superseded keystroke. Order of arrival stops mattering, because only the current, un-aborted request is allowed to write.

The same mechanism is also passed as the callback's third parameter (the only option before 3.5, and still supported):

TypeScript
1
2
3
4
5
watch(query, async (q, oldQ, onCleanup) => { const controller = new AbortController(); onCleanup(() => controller.abort()); // ... });

The two forms differ here: onWatcherCleanup must be called before the first await — after it, Vue can't tell which watcher run it belongs to, so dev mode warns and the cleanup is silently dropped. The onCleanup argument is bound to its watcher, so it isn't subject to that constraint.

Both watch and watchEffect return a handle — call it (or .stop()) to stop; since 3.5 it also has .pause() / .resume():

TypeScript
1
2
3
4
5
const stop = watchEffect(() => { console.log(count.value); }); stop(); // this effect will never run again

You rarely need to call this yourself inside a component: a watcher created synchronously during setup() or <script setup> is automatically stopped when the component unmounts; one created later from an async callback (a setTimeout, a .then) inside that same <script setup> is not. You need the manual stop() mostly for watchers created outside a component's setup — inside a composable that runs after mount, in a store, or in plain JS — where Vue has no unmount to tie it to.

  • await breaks dependency discovery. watchEffect only tracks reads that happen synchronously before the first await. Anything read after an await inside the function is invisible to it and will not trigger a re-run if it changes.
  • A getter that reads nothing reactive never fires. watch(() => someObj.plainProperty, cb) looks correct but never runs if plainProperty isn't itself reactive — there's nothing for Vue to subscribe to.
  • watchEffect can over-track. Because it tracks everything the function body reads, a large conditional inside it can pick up dependencies you didn't intend, causing extra re-runs. watch with an explicit source avoids this by construction.
  • .push/.splice fire a non-deep watcher only when the source is the reactive() array itself (implicitly deep). watch(itemsRef, cb) on a ref([]), or a getter like () => state.items, tracks only the array reference — in-place mutations need deep: true, or watch () => items.value.length.
  • immediate: true hands undefined as oldValue on that first synthetic run (an empty array [] when watching an array of sources) — always guard against it if the callback compares old and new.

  • Reach for watchEffect when the job is "keep this side effect in sync with whatever it reads" and you don't need the old value — document titles, localStorage, imperative DOM syncing.
  • Reach for watch when you need the previous value, need the callback to stay silent until one specific thing changes (not everything the function happens to read), or want lazy behavior by default.
  • Default to flush: 'pre' (the default) unless the callback reads the DOM — then use 'post'. Reserve 'sync' for cache-invalidation-style work that must happen before Vue's own batching, and never on a source that's mutated many times in one tick, like an array.
  • Always pair an async watcher with cleanup the moment more than one run can be in flight at once — a search box, a resource fetched by an ID prop, anything driven by fast user input.
  • Avoid deep: true on large objects if you can watch a specific derived getter instead; the traversal cost is real.

It runs when it's created — during setup(), before mount — synchronously with the default flush: 'pre'; with flush: 'post' (watchPostEffect) even the first run waits until after render. That first run is also how it discovers its dependencies.

They're unrelated axes. deep controls how far into a reactive object watch traverses to detect a change. watchEffect always tracks only what it actually reads during a run, deep or shallow, because it has no fixed source to traverse — it just doesn't re-read nested properties unless your function body does.

The most common cause is watching a plain snapshot instead of a getter — see Stage 3. The second most common cause is watching a ref holding an object and mutating a nested property without deep: true (the object is reactive, but watch(ref) only tracks .value); the ref's own .value reference hasn't changed, so a shallow watch sees nothing.

Call the function watch/watchEffect returns: const stop = watch(...); stop();. For a watcher created synchronously inside <script setup>, you rarely need to — unmount does it for you.

No. computed() returns a cached value meant to be read elsewhere and must stay pure (no side effects). watchEffect runs a side effect and returns nothing meant to be read — it's for doing something, not deriving something.

Yes — async (newVal, oldVal) => { ... } works exactly as shown above. Just remember that oldVal and newVal are captured before the await, and that any onWatcherCleanup() call must happen before the first await.

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { ref, reactive, watch, watchEffect, onWatcherCleanup } from "vue"; // watch(): explicit source, lazy by default, gives old + new value watch(source, (newVal, oldVal) => { /* ... */ }); watch([a, b], ([newA, newB], [oldA, oldB]) => { /* ... */ }); watch(() => reactiveObj.prop, (newVal, oldVal) => { /* ... */ }); // getter for a nested property // watchEffect(): auto-tracked, runs immediately, no old value watchEffect(() => { document.title = title.value; }); // Options (watch only, except flush which both accept) watch(source, cb, { immediate: true, // also run once immediately (oldValue is undefined that time) deep: true, // traverse nested properties, not just the top reference once: true, // Vue 3.4+: auto-stop after first fire flush: "post", // 'pre' (default) | 'post' (after DOM update) | 'sync' (no batching) }); // Cleanup: cancel a stale async run before the next one starts (Vue 3.5+) watch(source, async (val) => { const controller = new AbortController(); onWatcherCleanup(() => controller.abort()); // before the first await try { await fetch(url, { signal: controller.signal }); } catch (e) { if (e.name !== "AbortError") throw e; } }); // Stopping const stop = watchEffect(() => { /* ... */ }); stop(); // manual — usually unnecessary for watchers created synchronously in <script setup>, which auto-stop on unmount // Vue 3.5+: the handle also has stop.pause() / stop.resume()

  • watch is told its source explicitly and is lazy by default; watchEffect discovers its sources by running once, immediately, and re-runs the whole function when any of them change.
  • flush decides when relative to the DOM update — 'pre' (default) can run before a DOM change, 'post' waits for it, 'sync' skips batching entirely.
  • onWatcherCleanup (Vue 3.5+) — or the callback's third onCleanup argument, the only option on earlier versions — fires right before the next run, which is exactly what stops a slow, stale async response from overwriting a fresher one.
  • A watcher created synchronously in <script setup> stops itself on unmount; one created outside a component's setup needs its returned stop() called by hand.

The search box from the top of this article isn't really a search-box bug — it's what happens any time a watcher's previous run outlives its next run and nobody told it to stop. Once cleanup is a habit rather than an afterthought, that entire category of bug stops happening. What's the last race condition you tracked down that turned out to be a missing cleanup? Drop it in the comments.

Runs right in your browser — poke at it and watch the concept react live.

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Keep reading

One post a day, in your inbox

Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.

0 comments

Sign in to join the discussion, like comments, and save articles for later.