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.

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()andwatchEffect()— not "one is explicit, one is automatic," but what that difference costs you - Pick the correct
flushtiming (pre,post,sync) for a watcher that needs to read the DOM - Use
onWatcherCleanupto 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.
- The problem: a watcher with no memory
- The mental model: pull vs. push tracking
- Stage 1: the smallest watch()
- Stage 2: the same job with watchEffect()
- Stage 3: multiple sources, and watching inside an object
- Stage 4: immediate, deep, and once
- Stage 5: flush timing — pre, post, sync
- Stage 6: cleanup and the race condition, fixed
- Stopping a watcher
- Edge cases and gotchas
- Best practices
- FAQ
- Cheat sheet
- Key takeaways
Here's the search box, written the way it's tempting to write it the first time:
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, areactive()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.
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.
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:
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:
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):
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 value | When it runs | Default? |
|---|---|---|
'pre' | Before the component re-renders | Yes, for both APIs |
'post' | After the component has re-rendered and the DOM is updated | No |
'sync' | Synchronously, the instant the dependency is written | No |
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:
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):
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):
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():
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.
awaitbreaks dependency discovery.watchEffectonly tracks reads that happen synchronously before the firstawait. Anything read after anawaitinside 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 ifplainPropertyisn't itself reactive — there's nothing for Vue to subscribe to. watchEffectcan 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.watchwith an explicit source avoids this by construction..push/.splicefire a non-deep watcher only when the source is thereactive()array itself (implicitly deep).watch(itemsRef, cb)on aref([]), or a getter like() => state.items, tracks only the array reference — in-place mutations needdeep: true, or watch() => items.value.length.immediate: truehandsundefinedasoldValueon 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
watchEffectwhen 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
watchwhen 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: trueon 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.
watchis told its source explicitly and is lazy by default;watchEffectdiscovers its sources by running once, immediately, and re-runs the whole function when any of them change.flushdecides 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 thirdonCleanupargument, 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 returnedstop()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.
Think it clicked? Take the 9-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
- Vue Composables: The Shared State Trap (+ Cheat Sheet)
- Vue nextTick Explained: How DOM Updates Are Batched
- Vue Reactivity Explained: ref vs reactive (+ Cheat Sheet)
🚀 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:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
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.