You type “lara”, the results for “lar” arrive last, and the list shows the wrong thing. Every live search written with watch has this bug until someone adds an AbortController, a clearTimeout, and a flag for the unmount case. Vue 3.5 folded all three into one call most people haven’t noticed: onWatcherCleanup().
TL;DR
- Inside a
watchorwatchEffectcallback,onWatcherCleanup(fn)registersfnto run before the next run of that watcher and when the watcher stops (component unmount). - Put
controller.abort()andclearTimeout(timer)in it, and a keystroke cancels the previous keystroke’s request and timer. No flags, no closures over “latest id”. - It must be called synchronously. After an
awaitit warns and registers nothing; use the callback’sonCleanupargument there. watch()returns a handle withpause(),resume()andstop(). Resume runs the callback once with the latest value if the source changed while paused.
What onWatcherCleanup is and why people miss it#
onWatcherCleanup shipped in Vue 3.5.0 (September 3, 2024) as part of the reactivity rewrite, next to bigger headlines: reactive props destructure, useTemplateRef(), useId(), lazy hydration. It is one short entry in the Reactivity API page, and the watchers guide mentions it only under “Side Effect Cleanup”. If you learned watch on Vue 3.0, you learned the third-argument onCleanup and never looked again.
The idea: while a watcher callback is running, Vue knows which watcher is active. onWatcherCleanup(fn) attaches fn to that watcher. Vue calls every attached cleanup before the callback runs again and when the watcher is stopped, which happens automatically when the component that created it unmounts.
The usual way vs the gem#
watch + manual bookkeeping, then onWatcherCleanup:
let timer: ReturnType<typeof setTimeout> | undefined
let controller: AbortController | undefined
watch(query, (q) => {
clearTimeout(timer)
controller?.abort()
controller = new AbortController()
const signal = controller.signal
timer = setTimeout(async () => {
results.value = await fetcher(q, signal)
}, 250)
})
onUnmounted(() => {
clearTimeout(timer)
controller?.abort()
})watch(query, (q) => {
const controller = new AbortController()
const timer = setTimeout(async () => {
results.value = await fetcher(q, controller.signal)
}, 250)
onWatcherCleanup(() => {
clearTimeout(timer)
controller.abort()
})
})No module-level variables, nothing to reset, and the unmount case is covered because stopping the watcher runs the same cleanup.
Real-world example: a live search against a Laravel endpoint#
The Laravel side is a plain JSON route. Validate the query, cap the results, and let the database do the work:
use App\Http\Controllers\PostSearchController;
Route::middleware('auth')->get('/posts/search', PostSearchController::class)->name('posts.search');<?php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\Request;
class PostSearchController
{
public function __invoke(Request $request)
{
$q = $request->string('q')->trim()->limit(100, '');
return Post::query()
->whereLike('title', "%{$q}%")
->orderByDesc('published_at')
->limit(10)
->get(['id', 'title', 'slug']);
}
}The composable: a debounced, cancellable search that any component can use.
import { onWatcherCleanup, ref, watch, type Ref } from 'vue'
export type Fetcher<T> = (query: string, signal: AbortSignal) => Promise<T[]>
export function useLiveSearch<T>(query: Ref<string>, fetcher: Fetcher<T>, delay = 250) {
const results = ref<T[]>([]) as Ref<T[]>
const loading = ref(false)
const handle = watch(query, (q) => {
const controller = new AbortController()
const timer = setTimeout(run, delay)
// Runs when `query` changes again, or when the watcher is stopped (component unmount).
onWatcherCleanup(() => {
clearTimeout(timer)
controller.abort()
})
async function run() {
if (q.trim().length < 2) {
results.value = []
return
}
loading.value = true
try {
results.value = await fetcher(q, controller.signal)
} catch (error) {
if ((error as Error).name !== 'AbortError') throw error
} finally {
if (!controller.signal.aborted) loading.value = false
}
}
})
return { results, loading, pause: handle.pause, resume: handle.resume, stop: handle.stop }
}And the component, which knows nothing about timers or controllers:
<script setup lang="ts">
import { ref } from 'vue'
import { useLiveSearch } from '@/composables/useLiveSearch'
interface Result {
id: number
title: string
slug: string
}
const q = ref('')
const { results, loading } = useLiveSearch<Result>(q, async (query, signal) => {
const response = await fetch(`/posts/search?q=${encodeURIComponent(query)}`, {
signal,
headers: { Accept: 'application/json' },
})
if (!response.ok) throw new Error(`Search failed: ${response.status}`)
return response.json()
})
</script>
<template>
<label for="post-search" class="sr-only">Search posts</label>
<input id="post-search" v-model="q" type="search" placeholder="Search posts…" autocomplete="off" />
<p v-if="loading">Searching…</p>
<ul v-else-if="results.length">
<li v-for="r in results" :key="r.id"><a :href="`/posts/${r.slug}`">{{ r.title }}</a></li>
</ul>
</template>What the tests showed#
We drove the composable with Vitest’s fake timers and a fake fetcher that rejects with an AbortError when its signal fires, the way fetch() does. Typing l, la, lar, lara with a 50 ms debounce and a 100 ms “network”:
| Keystroke | What happened |
|---|---|
l | Timer fired, query too short, results cleared, no request |
la | Timer cleared by the next keystroke’s cleanup: no request was ever sent |
lar | Request sent, then aborted by the cleanup when lara arrived |
lara | Request sent and resolved: ['result for lara'], loading back to false |
Two requests instead of four, and the stale one can’t overwrite the list. Unmounting the component in the middle of a request aborted it too, with no onUnmounted in sight.
Tip: using Inertia's useHttp instead of fetch?
Same pattern. Our useHttp live search had to call search.cancel() by hand on every keystroke. Put that call inside onWatcherCleanup() and the watcher does it for you, including on unmount.
The one rule: before the first await#
onWatcherCleanup() finds “the current watcher” through a global that Vue sets while your callback runs synchronously. Once your callback awaits, that global is gone. Calling it afterwards logs this warning and registers nothing:
[Vue warn]: onWatcherCleanup() was called when there was no active watcher to associate with.Our test confirmed it: a cleanup registered before the await ran, one registered after it never did, and the warning appeared once per callback run. The second argument, failSilently, swallows the warning but still registers nothing.
If you really need to register a cleanup after an await, use the callback’s own argument. It is bound to the watcher instance, so it works at any point:
watch(query, async (q, _previous, onCleanup) => {
const token = await getToken() // an await first...
const controller = new AbortController()
onCleanup(() => controller.abort()) // ...then this still works
results.value = await fetcher(q, token, controller.signal)
})watchEffect gets onCleanup as its first argument.
pause() and resume() on the watch handle#
Also new in 3.5: watch() returns a WatchHandle. Calling it still stops the watcher, and it now has pause(), resume() and stop() methods. A paused watcher ignores changes; resuming it runs the callback once with the latest value if the source changed in the meantime, and not at all if nothing changed.
const { results, pause, resume } = useLiveSearch<Result>(q, fetcher)
// Bulk-filling the input from a saved search? Don't fire a request per field.
pause()
q.value = saved.query
resume() // one request for the final valueIn our test, three changes while paused produced one callback run on resume, with the last value; pausing and resuming with no change produced none; after stop() nothing ran again.
Gotchas#
It is per run, not per watcher. Every run of the callback registers its own cleanups. Register inside the callback, not once outside it.
Cleanups run before the callback, not in parallel. The abort happens, then your new fetch starts. Code after await fetcher() in the old run still executes when the promise rejects: catch the AbortError (as above) or you will see unhandled rejections in the console.
loading and aborted runs. If you set loading = false in a finally without checking the signal, the old run’s finally turns the spinner off while the new request is still running. The composable checks controller.signal.aborted for that reason.
Watchers created after an await in setup() are not tied to the component. They don’t stop on unmount, so their cleanups don’t run then either. Create watchers synchronously in setup() or stop them yourself.
onWatcherCleanup is not onScopeDispose. onScopeDispose runs once when the effect scope ends. onWatcherCleanup runs on every re-run of one watcher and on its stop.
Try it yourself#
Prove the abort in one Vitest test
import { expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { useLiveSearch } from '@/composables/useLiveSearch'
it('aborts the previous keystroke', async () => {
vi.useFakeTimers()
const aborted: string[] = []
const q = ref('')
useLiveSearch(q, (query, signal) => new Promise((resolve) => {
const t = setTimeout(() => resolve([query]), 100)
signal.addEventListener('abort', () => { clearTimeout(t); aborted.push(query) })
}), 10)
q.value = 'lar'; await nextTick(); vi.advanceTimersByTime(20) // request for "lar" in flight
q.value = 'lara'; await nextTick() // cleanup aborts it
expect(aborted).toEqual(['lar'])
})Swap the fake for a real fetch and an AbortError shows up in the Network tab as a cancelled request.
How useful is this?#
| Score | |
|---|---|
| Time saved per use | ★★★★☆ Deletes two module variables, an onUnmounted and a flag per watcher |
| How often you’ll reach for it | ★★★★☆ Every watcher that fetches: search, filters, autocomplete, detail panels |
| Risk if misused | ★★☆☆☆ Only the “after await” trap, and Vue warns about it |
| Overall | 4 / 5: the correct default for async watchers since 3.5 |
If you have a watch that calls fetch or axios, you can switch today.
Recap#
onWatcherCleanup(fn)runsfnbefore the watcher’s next run and when it stops, including on unmount.- Abort the request and clear the timer there, and a live search can’t show stale results.
- Call it synchronously, before any
await; after anawait, use the callback’sonCleanupargument. watch()returns a handle:pause()ignores changes,resume()catches up once with the latest value,stop()ends it.
FAQ#
How do I cancel a fetch when a Vue watcher re-runs?
Create an AbortController inside the callback, pass controller.signal to fetch, and register onWatcherCleanup(() => controller.abort()) before any await. Vue aborts the previous request before the callback runs again and when the watcher stops.
Why does Vue warn "onWatcherCleanup() was called when there was no active watcher"?
You called it after an await (or outside a watcher). It must run synchronously during the callback. Move it above the first await, or use the onCleanup argument the callback receives.
Does onWatcherCleanup run when the component unmounts?
Yes, as long as the watcher was created synchronously in setup(). Unmounting stops the watcher, and stopping runs its registered cleanups. Our test aborted an in-flight request this way.
What happens to changes made while a watcher is paused?
They are ignored while paused. On resume(), if the source changed, the callback runs once with the latest value. If nothing changed, it does not run.
Which Vue version do I need?
onWatcherCleanup, pause() and resume() arrived in Vue 3.5.0 (September 2024). This post was tested on 3.5.44.
Next steps#
- Optimistic updates and useHttp in Inertia 3, the Inertia-flavoured live search
- Inertia 3.9: pause polling in background tabs
- Vue 3 Composition API cheat sheet
- Vue docs: Side Effect Cleanup
Sources#
- Vue docs: Watchers, Side Effect Cleanup and Stopping a Watcher
- Vue API: onWatcherCleanup() and the WatchHandle
- Vue 3.5.0 release and the 3.5.0 changelog entry:
onWatcherCleanup(#9927), pause/resume (#9651),deep: number(#9572) packages/reactivity/src/watch.ts, where the active-watcher check and the warning live



