In a Vue 3 `<script setup>` component, why are the watchers you create during setup stopped automatically when the component unmounts?
answer
- each instance owns something
- active only while setup runs
- effects register with the active container
- unmount calls scope.stop()
basics
~20 sEach Vue 3 component instance owns an effect scope that is active while setup runs, so watchers created synchronously there register with it; unmounting calls that scope's stop(), which stops them and runs its onScopeDispose callbacks.
solid answer
~40 sEvery component instance has its own `EffectScope`. Vue turns it on while `setup()` (which is what `<script setup>` compiles to) executes, so any `watch` or `watchEffect` created then registers with that active scope, and the component's render effect is created inside the same scope. On unmount Vue runs the `onBeforeUnmount` hooks, calls `scope.stop()` — stopping every collected effect, running `onScopeDispose` callbacks and stopping nested scopes — and afterwards runs `onUnmounted`. The catch is *synchronously*: a watcher created in a `setTimeout` or a promise callback after setup has returned finds no active scope and is not collected, and a scope made with `effectScope(true)` is detached on purpose, so both outlive the component unless you stop them yourself.
code
vue · 22 lines<script setup lang="ts">
import { ref, watch, watchEffect, effectScope } from 'vue'
const query = ref('')
// collected by the component scope: stopped at unmount
watch(query, q => console.log('search', q))
// nested, non-detached scope: a child of the component scope, stopped at unmount
effectScope().run(() => {
watchEffect(() => console.log('length', query.value.length))
})
// created after setup returned: no active scope, NOT stopped at unmount
setTimeout(() => {
watchEffect(() => console.log('late', query.value))
}, 100)
</script>
<template>
<input v-model="query" />
</template>go deeper
Recall that watchers created at the top level of setup are cleaned up for you when the component unmounts, and that timers or callbacks are where that stops being true.
Explain the mechanism: the instance's effect scope is active during setup, effects register with the active scope, and unmount calls scope.stop() between the beforeUnmount and unmounted hooks.
Show you can diagnose a leaked watcher by asking where it was created: after setup returned, inside a detached scope, or in a hand-written async setup after an await.
Frame automatic collection as an ownership rule the team can rely on, and set review conventions that flag effects created in callbacks or detached scopes without an explicit owner.
## What an effect scope is In Vue 3's reactivity package an **effect** is something that re-runs when the reactive state it read changes: the callback behind a `watch` or `watchEffect`, and the **render effect** that re-renders a component. An **effect scope** (`EffectScope`, created publicly with `effectScope()`) is a container that records the effects created while it is the *active* scope, so that all of them can be stopped with a single `stop()` call. Only one scope is active at a time; Vue keeps it in a module-level variable, and a new effect checks that variable at the moment it is constructed. ## How a component uses its own scope Every component instance is created with its own scope, and the lifecycle ties it in at three points: 1. **During setup.** Before calling `setup()` — the function `<script setup>` compiles into — Vue sets the current instance and turns the instance's scope on. Every `watch`, `watchEffect` or nested `effectScope()` created synchronously in that body sees it as the active scope and registers with it. When setup returns, the scope is turned off again. 2. **At first render.** The component's render effect is constructed while the same scope is on, so it is collected too. 3. **At unmount.** Vue runs the `onBeforeUnmount` hooks, then calls `scope.stop()`, then unmounts the rendered subtree, and queues the `onUnmounted` hooks to run after that. `scope.stop()` does three things, in this order: - stops every collected effect, so its callback never fires again and it unsubscribes from the state it read; - runs every callback registered with `onScopeDispose()` on that scope; - stops every nested, non-detached scope created inside it. That is why you rarely write teardown for watchers in a component: the component's scope owns them. ## What is collected and what escapes The rule is *the scope that is active when the effect is created*, which in practice means synchronously during setup. | Where the watcher is created | Stopped with the component? | Why | |---|---|---| | Top level of `<script setup>` | Yes | the component scope is active | | After a top-level `await` in `<script setup>` | Yes | the compiler wraps the await with `withAsyncContext`, which restores the instance and turns its scope back on | | Inside `effectScope().run()` during setup | Yes, through the nested scope | the nested scope became a child of the component scope | | Inside `effectScope(true).run()` during setup | No | a detached scope is never linked to a parent | | In a `setTimeout`, a promise `.then` or an event handler | No | setup has already returned and no scope is active | | In a plain module outside any component | No | there is no component scope at all | Anything in a 'No' row keeps running after the component is gone: its callback still fires on every change and it keeps whatever it closed over reachable, which is the classic leaked-watcher bug. You either stop it yourself with the handle `watch` returns, or create it inside a scope you control and stop that scope. One detail surprises people: component scopes are themselves created **detached**. A child component's scope is not a child of its parent's scope. Unmounting still cascades, because unmounting a parent unmounts its subtree and each child component stops its own scope on the way. A hand-written `async setup()` without the `<script setup>` compiler does not get the `withAsyncContext` restore, so effects and lifecycle hooks created after its first `await` are not tied to the component; register them before the first `await`. ## Computed values in 3.5 The API reference describes scopes as capturing 'computed and watchers'. In Vue 3.5 the reactivity core was rewritten around version counting, and a `computed` is no longer stored in the scope's effect list: it is a lazy subscriber that tracks its sources only while something is subscribed to it. When the render effect and the watchers that read it are stopped, the computed loses its last subscriber and unsubscribes from its sources, so it can be garbage-collected. The practical result is the same — nothing keeps running — but the mechanism differs from a watcher's. ## Why interviewers ask The question checks whether a candidate knows that cleanup is tied to **where and when an effect is created**, not to the file it sits in. Typical probes: - 'Your watcher is still logging after the route changed — why?' (it was created after setup returned, or inside a detached scope); - 'Do I need to stop this `watchEffect` in `onUnmounted`?' (no, if it is created synchronously in setup); - 'What stops the component's render effect?' (the same `scope.stop()` call); - 'Does setup run again on re-render, re-creating the watchers?' (no — setup runs once per instance, and re-renders only re-run the render effect). A strong answer names the component's scope, says it is active only during setup, and gives one example of an effect that escapes it.
- Why is a watcher created after a top-level await in `<script setup>` still stopped at unmount?The SFC compiler wraps each top-level `await` in `withAsyncContext`, which restores the current instance — and turns its scope back on — when execution resumes. The watcher therefore sees the component scope as active. A hand-written `async setup()` gets no such restore, so there you create watchers and lifecycle hooks before the first `await`.
- What stops a Vue 3 component's render effect when it unmounts?The same `scope.stop()` call. The render effect was constructed while the component's scope was on, so it is one of the scope's collected effects; Vue also marks the component's scheduler job as disposed so a queued update never runs for an unmounted instance.
saying these in an interview costs you the question
- Every watcher must be stopped in onUnmounted or it leaks
- A watchEffect created in setTimeout inside setup is still stopped on unmount
- Vue re-runs setup on each render, so old watchers get replaced
- An effectScope(true) created in setup is stopped with the component
- Any watcher after a top-level await in script setup escapes the component