skip to content

In Vue 3, how does onWatcherCleanup() differ from the onCleanup argument a watcher receives, and why can onWatcherCleanup fail inside an async callback?

level: middleimportance: nice to knowfreq 32%

answer

  1. import versus parameter
  2. which watcher is active right now
  3. synchronous part only
  4. 3.5 badge

basics

~20 s

onWatcherCleanup(), new in Vue 3.5, is imported and attaches to whichever watcher is currently running, so it only works synchronously. The onCleanup argument is bound to its own watcher, so it still registers after an await.

solid answer

~50 s

Both register a function that Vue runs before the watcher's next run and when it is stopped. `onCleanup` is a parameter: the third argument of a `watch` callback and the first argument of a `watchEffect` function, available throughout Vue 3. It is bound to that specific watcher, so calling it after an `await` still registers the cleanup. `onWatcherCleanup()` is imported from `vue` and was added in 3.5; it attaches the cleanup to the *currently active* watcher, which Vue only sets during the synchronous execution of the callback. After the first `await` there is no active watcher, so in development Vue warns that it was called with no active watcher and the cleanup is dropped. Its advantage is that a helper function called synchronously from the callback can register cleanup without the argument being threaded through.

code

ts · 13 lines
ts
import { ref, watch, onWatcherCleanup } from 'vue'

const channel = ref('general')

function subscribeWithCleanup(name: string) {
  const source = new EventSource(`/events/${name}`)
  onWatcherCleanup(() => source.close()) // runs synchronously inside the watcher
  return source
}

watch(channel, (name) => {
  subscribeWithCleanup(name)
}, { immediate: true })

go deeper

for a junior

Know both forms exist: the onCleanup parameter and the imported onWatcherCleanup(), and that the imported one is new in Vue 3.5.

for a middle

Explain the active-watcher mechanism and why it makes onWatcherCleanup synchronous-only, while the argument form stays bound to its watcher.

for a senior

Spot a late registration in review, including the production case where the dev warning is absent and a timer or subscription quietly leaks.

for a principal

Pick one convention for the codebase and state where teardown is registered, so helpers and reviewers follow the same rule.

## Two ways to register the same thing A Vue watcher can register **cleanup functions** that Vue calls: - right **before the watcher runs again** - for `watch`, before the callback is invoked with a new value; for `watchEffect`, before the effect function re-runs; - when the watcher is **stopped** - through its handle, or automatically when its owning component unmounts. There are two APIs for registering them, and they differ in how they find the watcher. ## The `onCleanup` argument `onCleanup` is handed to your function by Vue: ```ts watch(id, (newId, oldId, onCleanup) => { const timer = setInterval(poll, 1000, newId) onCleanup(() => clearInterval(timer)) }) watchEffect((onCleanup) => { const off = subscribe(channel.value) onCleanup(off) }) ``` - It is the **third** parameter of a `watch` callback and the **first** parameter of a `watchEffect` function. - It has existed throughout Vue 3. - It is **bound to its watcher** when the watcher is created. Calling it later - after an `await`, in a `.then()` - still registers the cleanup on the right watcher. The docs state this explicitly: the argument form is not subject to the synchronous constraint. ## `onWatcherCleanup()` ```ts import { watch, onWatcherCleanup } from 'vue' watch(id, (newId) => { const controller = new AbortController() onWatcherCleanup(() => controller.abort()) load(newId, controller.signal) }) ``` - It is **imported** from `vue` and was added in **Vue 3.5**. - It has no watcher parameter. It attaches the cleanup to the **currently active watcher** - an internal variable Vue sets just before calling your callback or effect function and restores right after the synchronous call returns. - Therefore it only works during the **synchronous execution** of the callback. ## Why it fails after an `await` An `async` function runs synchronously up to its first `await`, then returns a promise. Vue's call to your callback has already returned at that point, and Vue has restored the active watcher to whatever it was before - normally nothing. When the rest of the function resumes in a microtask: 1. `onWatcherCleanup(fn)` looks up the active watcher; 2. finds none; 3. in development, logs `onWatcherCleanup() was called when there was no active watcher to associate with.`; 4. does **not** register `fn`. In a production build the warning is gone and the cleanup is silently lost - a timer keeps ticking, a subscription keeps delivering. ```ts watch(id, async (newId) => { const data = await load(newId) onWatcherCleanup(() => {}) // too late: warns in dev, never registered }) ``` ## Choosing between them | | `onCleanup` argument | `onWatcherCleanup()` | |---|---|---| | Availability | all of Vue 3 | 3.5+ | | Obtained by | callback parameter | import from `vue` | | Called after an `await` | registers on its own watcher | not registered; dev warning | | Called from a helper | the helper must receive the argument | works if the helper runs synchronously inside the run | | Works in `watchEffect` | yes, first argument | yes | Practical guidance: - In 3.5 code, `onWatcherCleanup()` reads cleanly and lets a small helper (for example one that starts a subscription) register its own cleanup, as long as it is called synchronously. - If cleanup must be registered after asynchronous work, use the **argument** - but ask first whether it should have been registered earlier. A cleanup registered late cannot cancel what happened while the run was awaiting. - Keep one convention per codebase so reviewers know where to look for the teardown. ## Why Vue added the imported form The argument form works, but it couples every piece of teardown to the callback's signature. A watcher that starts a subscription, a timer and a request through three small helpers has to pass `onCleanup` into each of them. With `onWatcherCleanup()`, each helper registers its own teardown next to the resource it creates, which keeps the code that acquires a resource and the code that releases it side by side. The price is the synchronous-only rule, which is invisible in the helper's signature - so a helper that registers cleanup should say so in its name or documentation. ## A common misreading "Must be called synchronously" does not mean the callback must be synchronous. An `async` callback is fine; only the **registration** has to happen before the first `await`. Create the controller or the subscription, register its cleanup, then await.

  • If a Vue 3 codebase must support async registration, why not always use the onCleanup argument?
    It is a fine default, but it has to be threaded into every helper that owns a resource, which clutters signatures. `onWatcherCleanup()` lets helpers register their own teardown when called synchronously. Either way, the registration should normally precede the first await, since a late cleanup cannot undo work that already started in the meantime.
  • Can a helper function called from a Vue 3.5 watch callback register its own cleanup with onWatcherCleanup()?
    Yes, as long as the helper is called during the synchronous part of the watcher's run: the active watcher is still set, so the cleanup attaches to it. If the helper is called after an `await` in the callback, or from a timer, there is no active watcher and the registration is dropped with a development warning.

saying these in an interview costs you the question

  • onWatcherCleanup() can be called anywhere in an async callback, like onCleanup.
  • An async watch callback cannot use onWatcherCleanup at all.
  • onCleanup is the first argument of a watch callback.
  • A late onWatcherCleanup call throws, so the bug is always visible.
  • onWatcherCleanup has been available since Vue 3.0.