Watchers in depth
Control when a watcher runs with its options, cancel stale work with onWatcherCleanup(), and pause, resume or stop it.
What you'll learn
When you finish, you can do these.- 1Choose when a watcher runs and what counts as a change
- 2Cancel the previous search with onWatcherCleanup()
- 3Pause a watcher for a while, then resume or stop it
- 4Fix two mistakes that leave an old search running
Picture a shop page with a search box that asks a server for results on every keystroke. A watcher, code that Vue runs each time a value changes, sends the requests, but on its own it leaves three gaps. Answers can arrive out of order, so the results for "mu" land after those for "mug" and stay on screen. A saved search should run once when the page opens. And the page may need to pause searching without losing the text.
You need one idea from Reactivity basics: a ref, a box that holds a value and tells Vue when it changes. The Code tab holds the search page, App.vue, with a fake server and Pause, Resume and Stop buttons. Its search text is a ref, query, and watch(query, cb) makes the watcher: on every change, Vue calls cb, your callback function, with the new and the old text. By the end you can explain every line of that watcher, make it search at load and pause it.
Options decide when the callback runs
watch() takes a third argument, an object of options. Each one changes when the callback runs, or what counts as a change.
immediate: truealso runs the callback once during setup, while<script setup>runs, so a saved search runs at load.once: trueruns the callback on the first change and then stops the watcher.deep: truemakes Vue look inside an object, so a change inside it counts.flushdecides where the callback sits in Vue's update.
Vue does not call a callback at each assignment. It queues it and runs the queue once your code finishes.
state changesqueuedyour code finishes first; flush: sync runs at oncecallback runswith the new and old value, before the re-rendercomponent re-rendersflush: post runs the callback after this instead
With the default, flush: 'pre', the callback runs before the component re-renders: it sees the new state and the old page. flush: 'post' runs it after, for a callback that measures an element or scrolls to it. flush: 'sync', for the rare callback that must see every value, skips the queue and runs on every assignment, so two quick changes give two calls.
Depth matters only for objects, not the search text, so picture a cart: a ref, cart, holding an object whose items list gives each item a quantity. A ref that holds an object is watched shallowly: only a new .value counts. With { deep: true }, cart.value.items[0].qty = 2 counts too. Since Vue 3.5 a number sets the depth: deep: 1 follows only the properties of cart.value itself and misses that quantity.
Made with reactive() instead, Vue's other way to hold state, the cart is used without .value. Passed straight to watch(), it is watched deeply without asking. A getter, a function that returns what to watch, is not: watch(() => cart.items, cb) fires only when cart.items is replaced, because the getter keeps returning the same array. Watch () => cart.items[0].qty, or add { deep: true }.
onWatcherCleanup() cancels the previous run
onWatcherCleanup() keeps an old answer from landing after a newer one. In App.vue, each run asks for results, and typing again leaves the earlier request on its way. Added in Vue 3.5, onWatcherCleanup() registers a function that Vue calls before the next run of the same watcher, and when the watcher stops.
That function cancels the request with an AbortController, the browser's tool for cancelling: pass its signal to fetch(), which sends a request, and calling abort() stops it. In App.vue, fakeSearch plays the server: it takes the same signal and answers after 600 ms. Here is the watcher:
const handle = watch(query, async (text) => {
runs.value++
if (!text) {
result.value = 'Type to search'
return
}
const controller = new AbortController()
inFlight = controller
onWatcherCleanup(() => controller.abort())
result.value = `Searching for "${text}"…`
const count = await fakeSearch(text, controller.signal)
result.value = `${count} found for "${text}"`
})
Each run adds one to runs, the Watcher runs counter, writes its progress to result, the line under the box, and keeps its controller in inFlight for the Pause button. Type "mug" quickly: three runs start, two are aborted, and only the answer for "mug" appears.
Call onWatcherCleanup() before the first await, where the callback waits for the answer: Vue knows which watcher is running only up to that point.
Pause, resume and stop
watch() returns a handle, an object that controls the watcher; the three buttons of App.vue use it. handle.stop() ends the watcher for good and runs its cleanup. Vue 3.5 added handle.pause() and handle.resume() for a watcher that should rest for a while.
pause() does not run the cleanup, so a request already on its way still lands. The Pause button's function cancels it by hand:
function pause() {
handle.pause()
inFlight?.abort() // pause() does not run the cleanup, so cancel by hand
result.value = 'Paused: typing changes nothing'
}
Click Pause and type: the label stays at Paused and Watcher runs does not move. Click Resume, and the callback runs once with the latest text, not once per change. If the text did not change, nothing runs.
A watcher created during setup stops when its component unmounts, that is, leaves the page. One created later, inside a setTimeout timer or a .then() that runs once a request answers, belongs to nothing and runs until you call its stop(). Create watchers during setup.
What goes wrong
Two mistakes leave an old search running. The first waits for the answer before registering the cleanup.
watch(query, async (text) => {
const controller = new AbortController()
const count = await fakeSearch(text, controller.signal)
onWatcherCleanup(() => controller.abort())
result.value = `${count} found for "${text}"`
})
Each finished search makes the preview show onWatcherCleanup() was called when there was no active watcher to associate with., and no search is ever cancelled. Register the cleanup first, then wait.
watch(query, async (text) => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort())
const count = await fakeSearch(text, controller.signal)
result.value = `${count} found for "${text}"`
})
The warning goes, and each letter aborts the search before it.
The second mistake trusts pause() to cancel work in progress.
function pause() {
handle.pause()
result.value = 'Paused: typing changes nothing'
}
Type mug and click Pause within 600 ms. The label says Paused, then the running search lands and replaces it with 3 found for "mug". Pausing stops future runs, not the current one, so cancel the work in flight yourself.
function pause() {
handle.pause()
inFlight?.abort()
result.value = 'Paused: typing changes nothing'
}
The label stays at Paused.
What to remember
immediate,once,deep(or a depth number) andflushchange when a callback runs.flush: 'post'is for callbacks that read the updated page.- A
reactive()object is watched deeply by default. A ref holding an object, or a getter returning one, is not. onWatcherCleanup()cancels the previous run's work before the next run and when the watcher stops. Call it before the firstawait.pause()skips runs without cleaning up, so abort its work yourself.resume()runs once if the source changed meanwhile, andstop()ends the watcher and runs the cleanup.- A watcher created in a timer or a
.then()is not stopped with its component. Create watchers during setup.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- Start with a saved search. Change the
queryline toconst query = ref('mug'). The box shows mug but the page says Type to search, becausewatch()waits for a change. Add{ immediate: true }as the third argument ofwatch(). On load the page now shows Searching for "mug"… and then 3 found for "mug", and Watcher runs says 1. - Count the runs across a pause. Clear the box, then type
mug: Watcher runs goes up by 3. Click Pause and typewarmer: the counter stays put. Click Resume: the counter goes up by 1 and the page shows 1 found for "mug warmer". Click Pause and Resume again without typing, and the counter does not move. - Move the
onWatcherCleanup(...)line below theconst count = await …line, clear the box and typemugquickly. The preview shows the warningonWatcherCleanup() was called when there was no active watcher to associate with., and the result line steps through m, mu and mug, because no search is cancelled. Move the line back above theawait, and only mug gets a result.