A Vue 3 composable that registers onUnmounted and a watcher works at the top of setup but leaks when called from a click handler — why?
answer
- who does onUnmounted belong to?
- an instance set only during setup
- dev warning, silent in production
- watchers not bound to a component
- bind at setup, act later
basics
~20 sVue attaches lifecycle hooks and watchers to the component whose setup is currently running; in a click handler there is no active instance, so onUnmounted is dropped with a dev warning and the watcher is never stopped automatically.
solid answer
~40 s`onUnmounted()` and similar APIs take no component argument; they attach to Vue's *current instance*, which is set only while a component's `setup()` runs (and while a lifecycle hook runs). Called from a click handler, the composable finds no instance: the dev build warns `onUnmounted is called when there is no active component instance to be associated with`, the hook is never registered, and the cleanup never runs. A watcher created there belongs to no component, so unmounting does not stop it. The fix is to call the composable synchronously at the top of setup and let the handler act through what it returns: an action function, or a ref the composable watches.
code
ts · 23 linesimport { shallowRef, watch, toValue, onWatcherCleanup, type MaybeRefOrGetter } from 'vue'
export function usePolling(url: MaybeRefOrGetter<string | null>, intervalMs = 5000) {
const data = shallowRef<unknown>(null)
watch(
() => toValue(url),
(target) => {
if (!target) return // nothing to poll yet
const load = () =>
fetch(target)
.then((res) => res.json())
.then((json) => { data.value = json })
.catch(() => { /* keep the last good value */ })
load()
const id = setInterval(load, intervalMs)
onWatcherCleanup(() => clearInterval(id)) // Vue 3.5+: runs on URL change and when the watcher stops
},
{ immediate: true },
)
return { data }
}go deeper
Recall the rule: call composables synchronously at the top of setup or script setup, never from handlers or timers.
Explain the current-instance mechanism: why hooks and watchers need setup to be running, and what the dev warning means.
Diagnose the leak from its symptoms and restructure it: bind at setup, act through a returned action or a watched ref, keep stop handles.
Turn the rule into team practice: lint or review checks for composable call sites and cleanup conventions in a shared composable library.
## The symptom A composable such as `usePolling(url)` registers a watcher and an `onUnmounted()` cleanup. Called at the top of `<script setup>`, it behaves. Called from a click handler, a `setTimeout` or a promise callback, a development build logs: > onUnmounted is called when there is no active component instance to be associated with. Lifecycle injection APIs can only be used during execution of setup(). In production there is no warning at all: the polling keeps running after the component is gone. ## Why: the active component instance Vue's lifecycle APIs (`onMounted()`, `onUnmounted()` and the rest) and `inject()` are plain functions that take no component argument. They find their component through an internal **current instance**, which Vue sets while it runs a component's `setup()` and clears when setup returns. Vue also sets it while it invokes a lifecycle hook's callback. Outside those windows there is no current instance, and a click handler runs long after setup has returned. The consequences: 1. **Lifecycle hooks are not registered.** Development builds warn; production silently does nothing, so the cleanup the composable relies on never runs. 2. **Watchers are not bound to the component.** A watcher created synchronously during setup is stopped when the component unmounts. One created in a callback is owned by nothing, and the guide says it must be stopped manually to avoid a leak. 3. **inject() cannot find its provider.** It warns that it "can only be used inside setup() or functional components" and returns nothing useful. ## The call-site rule The guide's rule is to call composables in `<script setup>` or `setup()`, **synchronously**, and in some cases inside lifecycle hooks such as `onMounted()`. Synchronously does not mean lexically inside setup. A call chain that starts in setup and never crosses a callback or an `await` is fine, which is why composables can call other composables. | Call site | Active instance? | Outcome | |---|---|---| | top level of `<script setup>` | yes | hooks and watchers bound | | helper called synchronously from setup | yes | bound | | synchronously inside an `onMounted()` callback | yes | bound to the same component | | click handler, `setTimeout`, `.then()` callback | no | hooks dropped with a dev warning; watchers leak | ## The fix: bind at setup, act later Move the composable call to the top level of setup and turn "do it on click" into one of two shapes: - **A returned action.** The composable returns `start()`, `stop()` or `execute()`, and the click handler calls it. The watcher and cleanup already exist; the handler only triggers work. - **A reactive input.** The composable accepts a ref or getter and watches it. The click handler assigns the ref; the composable reacts. Either way, everything that needs an owner is created during setup and bound to the component, and the handler only changes state. ## When a watcher genuinely must be created later Sometimes a watcher has to be created in response to an event. Keep the handle that `watch()` and `watchEffect()` return and call it when the work is done, or at the latest from an `onUnmounted()` registered during setup. Code that must create reactive effects entirely outside components, such as a module-level service, can group them in a dedicated effect scope and stop them together; that is a separate API with its own rules. ## A review checklist - Every `useX()` call sits at the top level of setup, not in a handler or a timer. - Every listener, timer or subscription a composable opens has a matching cleanup. - Any watcher created outside setup keeps its stop handle, and something calls it. - Development warnings about "no active component instance" are treated as bugs, because production gives no signal at all. The underlying lesson: a composable's cleanup guarantees are only as good as its call site, because ownership is decided by *when* the code runs, not where it is written.
- Is it safe to call a composable inside an onMounted() callback?Yes, synchronously. Vue sets the current instance while it runs a lifecycle hook's callback, so hooks and watchers the composable creates there attach to that component. It is useful for logic that needs the DOM. After an `await` inside that callback, the instance is gone again.
- Why does the leak show up in production without any warning?The 'no active component instance' message is emitted only in development builds. In production the lifecycle API simply returns without registering anything, and the unowned watcher keeps running, so the only symptoms are extra network calls or growing memory.
saying these in an interview costs you the question
- Every watcher stops automatically when the component that created it unmounts.
- Vue queues a lifecycle hook registered late and attaches it when possible.
- Wrapping the call in nextTick restores the component instance.
- A composable only needs to be written inside the component file to bind correctly.
- No warning in production means the composable was called correctly.