In a Vue 3 app, a mouse-position tracker is shared by many components; how do you create its listener and effects outside any component and dispose them when the last user unmounts?
answer
- one instance, many consumers
- must not belong to the first caller
- detached scope plus a counter
- each consumer releases on its dispose
basics
~20 sCreate the tracker once inside a detached effectScope(true), registering the listener's removal with onScopeDispose; count consumers, have each release through onScopeDispose in its own component scope, and stop the detached scope when the count reaches zero.
solid answer
~40 sKeep one module-level `EffectScope` and a consumer count. The first consumer creates `effectScope(true)` and calls `scope.run()` to build the refs, the `mousemove` listener and any watchers, with `onScopeDispose` removing the listener. It must be **detached**: otherwise the scope becomes a child of the first consumer's component scope and dies when that component unmounts while others still use it. Every consumer increments the count and registers `onScopeDispose` in its own component scope to decrement it; when the count hits zero it calls `scope.stop()` and clears the module variables, so the next consumer builds a fresh scope, since a stopped one cannot run again. Guard the release with `getCurrentScope()` so a call from outside any scope does not pin the count.
code
ts · 38 linesimport { effectScope, getCurrentScope, onScopeDispose, ref, type EffectScope } from 'vue'
function createMouse() {
const x = ref(0)
const y = ref(0)
const update = (e: MouseEvent) => {
x.value = e.pageX
y.value = e.pageY
}
window.addEventListener('mousemove', update)
// registers on the detached scope that is active inside run()
onScopeDispose(() => window.removeEventListener('mousemove', update))
return { x, y }
}
let subscribers = 0
let scope: EffectScope | undefined
let state: ReturnType<typeof createMouse> | undefined
export function useSharedMouse() {
if (!scope) {
scope = effectScope(true) // detached: not owned by the first caller
state = scope.run(createMouse)
}
// only count callers that have a scope to release from
if (getCurrentScope()) {
subscribers++
onScopeDispose(() => {
subscribers--
if (subscribers === 0 && scope) {
scope.stop() // removes the listener
scope = undefined
state = undefined
}
})
}
return state!
}go deeper
Recall that state shared by many components still has listeners and effects that need an owner, and that effectScope(true) creates a scope nobody else stops.
Explain why a nested scope would die with its first consumer and how onScopeDispose attaches to whichever scope is active at the call.
Build the reference-counted version, guard the release with getCurrentScope(), recreate the scope after stop(), and explain why the naive version passes a single-consumer demo.
Decide which shared resources deserve counted lifetimes and which may live for the whole page, and make that ownership explicit in shared code.
## The problem A mouse-position tracker is a good example of reactive state that should exist **once** for the whole page: a pair of refs updated by a single `mousemove` listener, maybe with a watcher that derives something from them. If every component that wants the position creates its own copy, you get one listener per component. If you create one copy at module level and never dispose it, the listener lives for the whole session even when no component needs it. The goal is a shared instance that starts on first use and stops on last release. Two Vue 3 pieces solve it: a **detached effect scope** to own the shared effects, and **reference counting** driven by each consumer's own scope. ## Why the scope must be detached The first consumer calls the shared function during its setup, while its component scope is active. A plain `effectScope()` created there would record that component scope as its parent. When that first component unmounts, the parent's `stop()` stops its children — tearing the tracker down for every other component still showing the position. `effectScope(true)` breaks that link. A detached scope ignores whatever scope is active when it is created, so no component owns it. It lives until your code calls its `stop()` — which is why the counting below is not optional. ## Reference counting with onScopeDispose Each call does the following: 1. increments a module-level `subscribers` counter; 2. if no shared scope exists, creates `effectScope(true)` and calls `scope.run()` to build the state; inside `run()` the detached scope is active, so `onScopeDispose` registers the listener removal **on the detached scope**; 3. after `run()` returns, the caller's own scope is active again, so a second `onScopeDispose` registers the **release** on the consumer's component scope; 4. the release decrements the counter and, at zero, calls `scope.stop()` and clears the module variables. When the last consumer unmounts, its component scope stops, the release runs, the detached scope stops, its dispose callback removes the listener, and any watchers inside it stop. ## Edge cases to handle - **No owner scope.** If the function is called at module level or in a timer, `onScopeDispose` has no scope to attach to: it warns and registers nothing, but the counter was already incremented, so the tracker can never reach zero. Guard with `getCurrentScope()` and only count consumers that can release. - **Restarting.** A stopped scope is permanently inactive; `run()` on it skips the function and returns `undefined`. Clear the variable and create a new detached scope on the next first use. - **Server rendering.** There is no `window` on the server and no unmount there either, so add browser-only listeners behind a client check or from `onMounted`. - **Never stopping on purpose.** If the state is genuinely needed for the life of the page, a detached scope that is never stopped is a legitimate choice — as long as it is a decision, not an accident. | Choice | Listeners | Disposal | |---|---|---| | One copy per component | one per consumer | automatic, per component | | Module-level, no scope | one | never | | Detached scope, never stopped | one | never, by decision | | Detached scope plus counter | one | when the last consumer unmounts | ## Why a non-detached scope looks fine in testing With a single consumer, a nested scope works: that consumer is also the last one. The bug appears only when the first consumer unmounts before the others — a route change that removes a header but keeps a sidebar, say. The remaining components keep their stale refs, which silently stop updating. That is why interviewers like the question: the naive version passes a demo. ## What the answer demonstrates A strong answer names `effectScope(true)` and says **why** it is detached, shows that `onScopeDispose` attaches to *whichever scope is active* at the call — the detached one inside `run()`, the consumer's own outside it — and handles the counter's edge cases.
- In this Vue 3 pattern, why does the second onScopeDispose() register on the consumer's scope and not on the detached one?`onScopeDispose` attaches to whichever scope is active at the call. `scope.run()` makes the detached scope active only while `createMouse` runs, then restores the caller's scope. The release is registered after `run()` returns, so it lands on the consumer's component scope and fires when that component unmounts.
- What goes wrong if useSharedMouse() is called from outside any component or scope?Without the guard, the counter is incremented but `onScopeDispose` has no scope to attach to, so the release never runs and the tracker cannot reach zero. With the `getCurrentScope()` guard that caller simply does not count, and the tracker still stops when the scoped consumers are gone.
saying these in an interview costs you the question
- A plain effectScope() is enough because the first consumer owns it
- A detached scope is stopped automatically when all consumers unmount
- Calling run() again on the stopped scope restarts the tracker
- Every onScopeDispose call registers on the detached shared scope
- Each consuming component needs its own mousemove listener to get updates