A Vue 3 `useFetch` composable watches a module-level `baseUrl` ref; tests call it directly, and later tests record extra fetches. Why, and how does `effectScope()` fix it?
answer
- who owns a watcher outside a component
- shared source, orphaned subscriber
- collect effects, stop them together
- onScopeDispose for the composable's cleanup
basics
~20 sWatchers created by a direct call belong to no component, so nothing stops them; when a later test changes the shared baseUrl, old watchers still fire and fetch. Run each call inside effectScope().run() and call scope.stop() after the test.
solid answer
~40 sInside a component, every `watch` created during `setup` is collected by the component's effect scope and stopped on unmount. A direct call in a test has **no owner**: the watcher lives until someone calls its stop handle, and `useFetch` does not return one. As long as its source is a per-test ref, that is harmless, but `baseUrl` is **module-level**, so it outlives every test: when test C changes `baseUrl.value`, the watchers left behind by tests A and B react and call `fetch` again. The fix is to give each call an owner: create `const scope = effectScope()`, run the composable with `scope.run(() => useFetch(path))`, and call `scope.stop()` in `afterEach`. That stops every watcher created inside and runs the composable's `onScopeDispose` callbacks, such as aborting an in-flight request.
code
ts · 18 linesimport { ref, watch, onScopeDispose, type Ref } from 'vue'
export const baseUrl = ref('/api')
export function useFetch(path: Ref<string>) {
const data = ref<unknown>(null)
let controller: AbortController | undefined
watch([baseUrl, path], async ([base, p]) => {
controller?.abort()
controller = new AbortController()
const res = await fetch(base + p, { signal: controller.signal })
data.value = await res.json()
}, { immediate: true })
onScopeDispose(() => controller?.abort())
return { data }
}go deeper
Recall that a watcher created outside a component keeps running until stopped, and that effectScope lets a test stop everything a composable created.
Explain component scopes: setup runs with the component's scope active, so unmount stops its watchers, while a direct call has no active scope and no owner.
Diagnose order-dependent tests by looking for long-lived sources such as module refs or stores, and standardise scope.run plus scope.stop, with stop before shared-state resets.
Set composable conventions, such as onScopeDispose for cleanup and avoiding module-level mutable state, so leaks are prevented in the code rather than patched in each test.
## The symptom A test file calls `useFetch(path)` directly, which is fine for a composable that only uses reactivity APIs. Each test passes alone. Run together, a later test sees `fetch` called more often than it expects, and the extra calls carry URLs from earlier tests. The count grows with the number of tests that ran before. ## Who owns a watcher Vue 3 collects **reactive effects**, such as `watch`, `watchEffect` and component render effects, into **effect scopes**. Every component has one: - while `setup()` runs, the component's scope is **active**, so each `watch` created there is registered in it; - when the component unmounts, Vue stops that scope, which stops all the watchers registered in it. A test that calls `useFetch()` directly has no component, so no scope is active. The watcher is created with **no owner**. It only stops if someone calls the stop handle that `watch` returns, and the composable does not expose it. ## Why the leak shows up here An ownerless watcher is not a problem by itself. If its only source is a ref created inside the test, nothing changes that ref after the test, and the whole group becomes garbage together. The trouble is a **source that outlives the test**: 1. `baseUrl` is a `ref` declared at module scope, shared by every test in the file. 2. Test A calls `useFetch('/users')`; its watcher subscribes to `baseUrl`. 3. Test B does the same; now two watchers are subscribed. 4. Test C sets `baseUrl.value = '/v2'` and expects one fetch. Every watcher left by A and B runs as well. The same shape appears with any long-lived source: a store's state, a singleton composable's shared state, or a global like `window` size refs. ## The fix: an explicit scope per test `effectScope()` creates a scope you control. Effects created inside `scope.run(fn)` are collected by it, and `scope.stop()` disposes of all of them at once. ```ts let scope: EffectScope beforeEach(() => { scope = effectScope() }) afterEach(() => { scope.stop() }) test('fetches the initial path', () => { const path = ref('/users') scope.run(() => useFetch(path)) expect(fetchSpy).toHaveBeenCalledTimes(1) }) ``` `scope.stop()` does three things: - stops every `watch` and `watchEffect` created in the scope; - stops any nested scopes created inside it; - runs the callbacks registered with `onScopeDispose()` while the scope was active. ## Cleanup inside the composable: `onScopeDispose` A `useFetch` that wants to abort an in-flight request when its owner goes away should register that cleanup with `onScopeDispose()`, not `onUnmounted()`: | Cleanup API | Runs when a component unmounts | Runs on `scope.stop()` | Works in a direct call | |---|---|---|---| | `onUnmounted` | yes | no | no, warns without an instance | | `onScopeDispose` | yes, because setup runs in the component's scope | yes | only inside an active scope | `onScopeDispose` called with no active scope logs a dev warning that there is no active effect scope, which is another sign that a test forgot its scope. ## Timing in the direct call Watchers default to `flush: 'pre'`. Even outside a component, a changed source queues the callback on Vue's scheduler rather than running it synchronously, so after `path.value = '/teams'` the test must await `nextTick()` before counting fetches. With `immediate: true`, the first run happens synchronously inside `scope.run`, which is why the first assertion above needs no await. ## Finding the leak in an existing suite 1. Run the failing test alone; if it passes, suspect shared state rather than the composable's logic. 2. Search the composable for sources declared outside the function, such as module-level refs, and for watchers on store state. 3. Count fetch calls per test and check whether the extra calls carry URLs from earlier tests; that fingerprint points to orphaned watchers rather than a double trigger inside one call. ## Checklist 1. Direct calls of watcher-creating composables go inside `scope.run`. 2. `scope.stop()` in `afterEach`, before any shared state is reset, so resets do not trigger old watchers. 3. Composables clean up with `onScopeDispose`, so the same cleanup runs in components and in tests.
- Why stop the scope before resetting `baseUrl` in `afterEach`, rather than after?Resetting `baseUrl` is itself a change to a watched source. If the scope is still alive, the current test's watcher queues another fetch against the reset URL, which can resolve during the next test. Stopping first detaches every watcher, so the reset is silent.
- Would returning the stop handle from `useFetch` be a better fix than `effectScope`?It works for one watcher, but it leaks the implementation into the API and breaks as soon as the composable adds a second watcher. A scope collects everything created inside without the composable exposing anything, and it also runs `onScopeDispose` cleanups, which a single stop handle does not.
An effect scope is like a bar tab: every subscription opened while the tab is open goes on it, and closing the tab settles all of them at once instead of chasing each one.
saying these in an interview costs you the question
- Watchers created in a test are stopped automatically when the test function ends.
- Garbage collection will stop old watchers even while their source is still referenced.
- onUnmounted is the right cleanup API for a composable called directly in tests.
- scope.stop() only stops watchers and never runs any registered cleanup callbacks.
- A watch callback outside a component runs synchronously when its source changes.