In a Vue 3.5 search-as-you-type component, how do you make a watch on the query abort the previous request and never show stale results?
answer
- responses arrive out of order
- cleanup runs before the next run
- register it before any await
- AbortController plus an aborted check
basics
~20 sCreate an AbortController in the watch callback and register onWatcherCleanup(() => controller.abort()) before the first await. Vue runs that cleanup before the callback runs again, so the older request is cancelled and its late result is ignored.
solid answer
~40 sWithout cleanup, each keystroke starts a request, and a slow older response can resolve last and overwrite newer results. In Vue 3.5 I create an `AbortController` inside the `watch(query, async (q) => ...)` callback, pass its `signal` to `fetch`, and call `onWatcherCleanup(() => controller.abort())` synchronously, before the first `await`. Vue runs registered cleanups right before the callback runs again for a new value, and when the watcher is stopped, including on unmount. I also check `controller.signal.aborted` before writing results and ignore the abort error, so a response that slipped through never lands. If the async API cannot be cancelled, the same cleanup sets a `stale` flag instead. The third callback argument, `onCleanup`, does the same job and also works before Vue 3.5.
code
vue · 31 lines<script setup lang="ts">
import { ref, watch, onWatcherCleanup } from 'vue'
interface Hit { id: string; title: string }
const query = ref('')
const results = ref<Hit[]>([])
watch(query, async (q) => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort())
if (!q) {
results.value = []
return
}
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
signal: controller.signal,
})
const data: Hit[] = await res.json()
if (!controller.signal.aborted) results.value = data
} catch {
// aborted or failed: a newer run owns the results
}
})
</script>
<template>
<input v-model="query" placeholder="Search" />
<ul><li v-for="hit in results" :key="hit.id">{{ hit.title }}</li></ul>
</template>go deeper
Remember that responses can arrive out of order and that Vue lets a watcher register a cleanup, onWatcherCleanup or onCleanup, that runs before its next run.
Explain when cleanups run (before the callback re-runs, and on stop) and why the registration must happen before the first await.
Write the full pattern - per-run AbortController, early registration, guarded writes, swallowed abort errors - and a stale flag when the API cannot be cancelled.
Push the pattern into a shared composable or data layer so every async watcher cancels the same way, instead of each component inventing its own guard.
## The race A search box bound to `query` with a watcher that fetches results looks innocent: ```ts watch(query, async (q) => { results.value = await searchApi(q) }) ``` Type `v`, `vu`, `vue` quickly and three requests are in flight. Network latency is not ordered: if the request for `vu` is the slowest, it resolves last and **overwrites** the results for `vue`. The input shows one query, the list shows another. Nothing in the watcher tells Vue that the older run is obsolete. ## Vue's cleanup hook for watchers Vue gives each watcher run a way to register **cleanup functions**: - **`onWatcherCleanup(fn)`** - imported from `vue`, new in **3.5**. It attaches `fn` to the watcher that is currently running. - **`onCleanup(fn)`** - passed as the **third argument** of a `watch` callback and the **first argument** of a `watchEffect` function; available before 3.5 as well. Registered cleanups run at two moments: 1. **Right before the callback runs again.** For `watch`, that is when the source produced a new value (or `deep` / a forced trigger applies); Vue calls the previous run's cleanups, then the callback. 2. **When the watcher is stopped** - by calling its handle, or automatically when the owning component unmounts. That ordering is exactly what a race needs: the old run is cancelled *before* the new one starts. ## The pattern ```ts watch(query, async (q) => { const controller = new AbortController() onWatcherCleanup(() => controller.abort()) // before any await if (!q) { results.value = [] return } try { const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, { signal: controller.signal, }) const data = await res.json() if (!controller.signal.aborted) results.value = data } catch (e) { if (!controller.signal.aborted) error.value = String(e) } }) ``` - **Register first.** `onWatcherCleanup` only works during the synchronous part of the callback. After the first `await`, no watcher is active; in development Vue warns and the cleanup is simply not registered. - **Abort, then guard.** Aborting rejects the pending `fetch` or body read with an abort error. The `aborted` check covers the narrow window where data resolved but the continuation had not run yet, and keeps the abort error out of the UI. - **Early returns are fine.** The controller exists and its cleanup is registered even when the callback returns early. ## When the request cannot be aborted Some async work - a third-party SDK, a web worker message, an IndexedDB call - has no cancel signal. The cleanup can still mark the run as stale: ```ts watch(query, async (q, _old, onCleanup) => { let stale = false onCleanup(() => { stale = true }) const data = await sdk.search(q) if (!stale) results.value = data }) ``` The work still completes, but its result is discarded. Aborting is better when available because it also frees the network and the server. ## `onWatcherCleanup` versus `onCleanup` | | `onWatcherCleanup()` | `onCleanup` argument | |---|---|---| | Version | 3.5+ | all of Vue 3 | | How you get it | import from `vue` | callback parameter | | After an `await` | not registered (dev warning) | works - bound to its watcher | | Usable in helpers | yes, if called synchronously during a run | only if passed down | Either way, register the cleanup **before** the first `await`. With `onCleanup` a late registration does not fail, but it is attached too late: if the query changed while this run was awaiting, the new run has already started and this request was never aborted. ## Checklist - One `AbortController` per run, created inside the callback. - Cleanup registered synchronously, first thing in the callback. - Result writes and error writes guarded by the run's own `aborted` or `stale` flag. - Debouncing reduces the number of requests but does **not** remove the race; the cleanup still matters.
- Does debouncing the Vue 3 search watcher remove the need for cleanup?No. Debouncing lowers how many requests start, but two requests can still overlap whenever the user pauses longer than the debounce delay and then types again while the first is in flight. The response order is still not guaranteed, so the abort or stale-flag cleanup is what guarantees the latest query wins.
- In Vue 3, what happens to an in-flight search request when the component unmounts?A watcher created synchronously in setup is stopped with the component, and stopping a watcher runs its registered cleanups. So the same `onWatcherCleanup(() => controller.abort())` also aborts the request on unmount, and no result is written into a component that is gone.
Each watcher run is a courier sent with a recall note pinned on. Before Vue dispatches the next courier, it reads the previous note and calls that courier back, so only the latest delivery can reach the door.
saying these in an interview costs you the question
- Vue cancels the previous async watch callback automatically when the source changes.
- Registering the cleanup after the await is fine because it runs on the next change anyway.
- Debouncing the input fully prevents out-of-order responses.
- The cleanup runs only when the component unmounts.
- An aborted fetch needs no handling because it resolves with empty data.