Vue 3 watch() Cleanup: Why Some Watchers Never Stop
Our settings drawer kept saving after it was closed. Not on close, which would have been reasonable, but minutes later, while my colleague was three screens away from it. PATCH requests arriving in the network tab carrying values she had typed into a drawer that no longer existed. She sent me a screenshot with the timestamps highlighted, assuming the API was queueing something.
The watcher behind those writes was not broken. It watched the right object, deep, and did everything it was told for as long as the page lived. It was also ownerless. I had created it inside a debounce timer, and a watch() call made where Vue cannot see a component has nobody to stop it. Not at unmount, and not when the route changes.
It is the mirror image of a bug I wrote about a few weeks ago, when a watchEffect stopped tracking a filter after an await. That one went quiet too early. This one never goes quiet at all.
One lookup decides who owns a watcher
The lifecycle binding is one lookup, taken when you call watch or watchEffect: which effect scope is active at that moment. Vue keeps the answer.
// packages/reactivity/src/watch.ts
const scope = getCurrentScope()
const watchHandle = () => {
effect.stop()
if (scope && scope.active) {
remove(scope.effects, effect)
}
}
That is the whole arrangement. The handle stops the effect and unhooks it from the scope that owned it. If scope is undefined, the second half has nothing to work with.
Every component creates one scope for itself, and the effects created while it is current get collected into it. Unmounting stops the scope, which walks that list and stops each effect. Watchers created in setup are on the list. Watchers created where the scope was not current are on no list at all, which is how a drawer that no longer exists keeps sending saves.
Where Vue actually turns the scope on
Vue turns the scope on around the code it runs with a component in hand. Switching the scope on is part of setting the current instance:
// packages/runtime-core/src/component.ts
export const setCurrentInstance = (instance) => {
const prev = currentInstance
internalSetCurrentInstance(instance)
instance.scope.on()
return () => {
instance.scope.off()
internalSetCurrentInstance(prev)
}
}
Lifecycle hooks go through that wrapper, which explains a difference I had never thought about. Same hook, one wrapper apart:
onMounted(() => {
watch(settings, persist, { deep: true }) // owned by the component
})
onMounted(() => {
setTimeout(() => {
watch(settings, persist, { deep: true }) // owned by nobody
}, 300)
})
The second callback runs long after the hook returned and the reset took the scope down. Same story after an await in an async callback, inside an event handler I registered by hand, in a .then() callback, or in a plain module called from outside setup. Print the answer where you create it:
setTimeout(() => {
console.log('scope here:', getCurrentScope()) // undefined
watch(settings, persist)
}, 300)
Vue does guard one async case. If a component unmounts while an async setup is still pending, effects created when that setup resumes are built without the active flag and never subscribe to anything. That guard needs a live scope, which a timer callback is not.
Owning a watcher you have to create late
Usually the fix is to create the watcher in setup, synchronously, where the scope is open. When the timing cannot move, because you are waiting on a user action or a first paint, hand it a scope of your own:
const panelScope = effectScope()
onMounted(() => {
setTimeout(() => {
panelScope.run(() => {
watch(settings, persist, { deep: true })
})
}, 300)
})
onBeforeUnmount(() => panelScope.stop())
A scope created during setup is a child of the component's scope, so unmounting would stop it anyway. The explicit stop lets you kill those watchers on your own schedule, when the panel is disposed rather than when the page navigates. Stopping a scope runs the effects' teardown too, so an in-flight request gets aborted.
For one or two watchers, keeping the handle is enough. const stop = watch(...), call it in onBeforeUnmount. What you cannot do is recover a watcher you never kept a handle to. The handle also has pause() and resume() since 3.5, the right tool for gating a watcher while a modal is open rather than killing it. Anything meant to be gone for good wants stop().
The cleanup that doubles as unmount teardown
Ownership and cleanup are separate questions, and the second has a trap. A watcher collects its cleanup functions into a list on the effect's onStop, so one registration covers two moments: the callback is about to run again, or the watcher is being stopped.
watch(query, async (q) => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort())
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
signal: controller.signal,
})
rows.value = await res.json()
} catch (err) {
if (err.name !== 'AbortError') throw err
}
})
The trap: onWatcherCleanup() works out which watcher it belongs to from whichever one is currently running, and that marker is put back the moment your callback hits its first await. Register one after an await and dev mode warns that no active watcher was available to associate it with, then drops it.
watch(query, async (q) => {
const rows = await load(q)
onWatcherCleanup(() => cache.delete(q)) // too late, ignored
})
The third callback argument is bound to the effect rather than the running watcher, so it survives the await:
watch(query, async (q, _previous, onCleanup) => {
const rows = await load(q)
onCleanup(() => cache.delete(q)) // fine
})
If the watcher is a watchEffect, its cleanup runs with dependency tracking paused, so teardown can read reactive values without turning them into dependencies. The rejection side of an aborted fetch is in the AbortController post.
Finding the ones already shipped
I grep for watch( and read the line above each hit. If it says setTimeout, setInterval, addEventListener, .then(, or an await, that watcher is mine to manage and almost certainly is not. Two of the three near that drawer were inside timers. The third came from a helper called from setup and from a click handler, so it got whichever context was current.
Both shapes live in Snippet Ark now, the hoisted one and the scope one, because I have written the broken version at least twice this year.
The point is smaller than that. A watcher's owner is decided by one call at creation time. Create one outside setup or a lifecycle hook and you are the owner, whether or not you asked for the job. Vue does that work quietly where it can see the component, and stays quiet where it cannot.