skip to content

In Vue 3, what does effectScope() return, and how do its run() and stop() methods dispose a group of effects together?

level: middleimportance: should knowfreq 35%

answer

  1. a container for effects
  2. active only inside the callback
  3. returns what the callback returns
  4. stop is final, nested scopes follow

basics

~20 s

effectScope() returns an EffectScope object. Effects created inside scope.run(fn) register with it and run() returns fn's result; scope.stop() stops them all, runs onScopeDispose callbacks and stops nested scopes, after which the scope is inactive for good.

solid answer

~40 s

`effectScope()` returns an `EffectScope`. `scope.run(fn)` makes the scope the active one for the synchronous duration of `fn`, so every `watch`, `watchEffect` and nested scope created there registers with it, and it returns whatever `fn` returns. `scope.stop()` stops every collected effect, runs the callbacks registered with `onScopeDispose()`, and stops each nested scope that was not created detached. After `stop()` the scope is inactive permanently: `run()` no longer calls `fn`, returns `undefined` and warns in development, so you create a new scope to start again. It is the same mechanism a component uses for its own setup, exposed for code that lives outside components.

code

ts · 20 lines
ts
import { effectScope, ref, watch, watchEffect, onScopeDispose } from 'vue'

const counter = ref(0)
const scope = effectScope()

const api = scope.run(() => {
  watch(counter, n => console.log('changed to', n))
  watchEffect(() => console.log('now', counter.value))

  const id = setInterval(() => counter.value++, 1000)
  onScopeDispose(() => clearInterval(id))

  return { counter }
})

// later: stops both watchers and clears the interval
scope.stop()

scope.active // false
scope.run(() => 1) // undefined, fn not called, dev warning

go deeper

for a junior

Remember the shape: effectScope() gives an object, run() executes a function inside it and returns that function's value, stop() tears everything down.

for a middle

Explain that run() sets the active scope only for the synchronous call, and walk through what stop() does: effects, onScopeDispose cleanups, nested scopes, then inactive for good.

for a senior

Use scopes to give non-component code a clear owner, and spot where a detached scope or a callback created after run() returns breaks that ownership.

for a principal

Decide which long-lived features get their own scope and who calls stop(), so teardown is designed rather than discovered in a memory profile.

## What effectScope() gives you An **effect** in Vue 3 is a reactive computation that re-runs when state it read changes — the machinery behind `watch` and `watchEffect`. Each one holds subscriptions to reactive sources, so it stays alive (and keeps its closure reachable) until something stops it. Inside a component that something is the component itself. **Outside** a component — in a plugin, a module-level service, a test, or a feature that outlives the views using it — nothing stops them unless you do. `effectScope()` returns an `EffectScope` object: a container that collects effects so they can be disposed together. Its public shape: | Member | What it does | |---|---| | `run(fn)` | makes the scope active while `fn` runs synchronously, returns `fn`'s return value (or `undefined` if the scope is inactive) | | `stop()` | stops collected effects, runs `onScopeDispose` callbacks, stops nested non-detached scopes; permanent | | `active` | `true` until `stop()` has been called | | `pause()` / `resume()` | added in 3.5: suspends and resumes the scope's effects and nested scopes without disposing them | `effectScope(true)` creates a **detached** scope; see below. ## run(): making the scope active Vue keeps one *active* scope at a time. `run(fn)` saves the previous active scope, sets this one, calls `fn`, and restores the previous one in a `finally` block. Anything created **synchronously** inside `fn` that looks for the active scope finds this one: - `watch()` and `watchEffect()` register their underlying effect with it; - `onScopeDispose(cb)` pushes `cb` onto its cleanup list; - `effectScope()` (non-detached) becomes a child of it. Because `run()` returns `fn`'s value, the usual pattern is to build state inside the scope and hand it out: `const state = scope.run(() => createState())`. Code in a callback that fires later — a timer, a promise continuation — runs after `run()` has already restored the previous scope, so it is not collected. ## stop(): disposing the group `stop()` does, in order: 1. stops every collected effect, which unsubscribes it from its sources so its callback never fires again; 2. runs every cleanup registered with `onScopeDispose()` — the place to remove event listeners, clear timers or close sockets; 3. stops each nested, non-detached child scope; 4. unlinks itself from its parent scope, if it had one, so the parent does not keep it alive. After that the scope is **inactive for good**. There is no restart: calling `run()` again does not execute `fn`, returns `undefined`, and in development logs 'cannot run an inactive effect scope.'. To start over you create a new scope. `pause()`/`resume()` are not a way around this — they only act on an active scope. ## Nesting and detached scopes A scope created while another is active becomes its **child**; stopping the parent stops the child. This composes: a feature can own sub-features, and one `stop()` at the top tears the whole tree down. Passing `true` opts out. A **detached** scope ignores the active scope when created, so it is never stopped by a parent — not by an enclosing scope and not by a component that was running setup when it was created. It lives until someone calls its own `stop()`. That is exactly what you want for something whose lifetime is independent of whoever happened to create it first, and exactly what leaks if nobody owns that `stop()`. ## Computed values The API reference says a scope captures 'computed and watchers'. Since the 3.5 reactivity rewrite, a `computed` is not kept in the scope's effect list: it tracks its sources only while it has subscribers. Once the watchers reading it are stopped, it unsubscribes and can be collected, so the group still winds down completely. ## Where it is used - **Components**: every instance has its own scope, active during setup; unmount calls its `stop()`. - **App-level services**: a feature started from a plugin or module creates a scope, runs its setup inside it, and calls `stop()` when the feature is torn down. - **Tests**: code that exercises reactive logic without mounting a component can run it inside a scope and stop the scope after the test, so no watcher leaks into the next one. The interview signal is knowing that **disposal follows the scope that was active at creation time**, and that `stop()` is final.

  • In Vue 3, what is the difference between stop() and the 3.5 pause() on an effect scope?
    `pause()` suspends the scope's effects and nested scopes so they skip running, and `resume()` brings them back; nothing is disposed and no `onScopeDispose` callback runs. `stop()` is final: effects are unsubscribed, cleanups run, children stop, and the scope can never run again.
  • Why does a scope created inside another scope's run() stop when the outer one stops?
    When a non-detached scope is constructed while another is active, it records that scope as its parent and adds itself to the parent's list of child scopes. The parent's `stop()` walks that list and stops each child. Passing `true` to `effectScope()` skips the link.

A power strip: plug several devices into it and one switch cuts them all; a strip plugged into another strip goes off with it, while a device plugged straight into the wall (a detached scope) ignores that switch. Once the strip is switched off here, it cannot be switched back on; you get a new strip.

saying these in an interview costs you the question

  • scope.run() returns the scope itself, not the callback's result
  • Calling run() again reactivates a stopped scope
  • Effects created in a timer started inside run() are collected too
  • stop() only stops watchers and never runs any cleanup callbacks
  • A nested scope keeps running after its parent scope is stopped