Using effectScope for Vue Cleanup

A composable that works perfectly inside components leaks when it is called from a store, a plugin, a router guard or an async callback: its watchers keep running, its computed values keep their dependencies alive, and memory grows with each call. This guide from Vue Reactivity and Memory Management, in Framework-Specific Memory Optimization, explains how Vue collects reactive effects into scopes, why effects created outside a component have no owner, and how effectScope gives them one.

Symptom Root Cause Immediate Action Measurable Impact
Watchers keep firing after the feature that created them is gone Created outside setup(), so no component scope stops them Run the code inside effectScope() and call scope.stop() on teardown All watchers and computeds stopped together
Composable leaks when called after an await in setup Code after await runs without an active component instance Create effects before the first await, or capture a scope Effects owned by the component again
Store/plugin creates effects per user or per entity Each call adds effects that are never disposed One scope per entity; stop it when the entity is removed Effect count tracks live entities
Computed values retain large reactive sources Active computeds keep their dependency graph alive Stop the owning scope Sources collectable
Cleanup logic scattered across stop handles Each watch returned stop function tracked by hand Replace with one scope and onScopeDispose Simpler, complete teardown

Root Cause: Effects Belong to Whatever Scope Is Active When They Are Created

Vue’s reactivity system tracks effects: every watch, watchEffect and computed is an effect that subscribes to the reactive sources it reads. Those subscriptions are two-way links — sources keep lists of subscribers, subscribers keep lists of dependencies — so a running effect keeps its closure alive, and the closure usually captures component state, props and possibly large reactive data.

To make cleanup automatic, Vue collects effects into an effect scope. During a component’s setup(), the component’s own scope is active, so every effect created there is registered with it; when the component unmounts, Vue stops the scope, which stops every effect in it. That is why watchers in components “just work” — the lifecycle rules described in preventing memory leaks in Vue watchers and computed.

Outside setup(), there may be no active scope. Code in a Pinia store action, a plugin’s install function, a router guard, a setTimeout callback, or setup code that runs after an await executes with no current component instance. Effects created there are ownerless: nothing will ever stop them unless you do so explicitly with the stop handle each watch returns. Composables written for components silently turn into leaks when called from these places, and each call adds another set of permanent watchers.

effectScope() solves this by letting you create a scope explicitly. Code run inside scope.run(() => …) registers its effects with that scope; scope.stop() stops all of them at once, runs any onScopeDispose callbacks registered inside, and stops nested child scopes too. A detached scope (effectScope(true)) is not collected by an outer scope, which is what you want for long-lived services with their own lifecycle. The rule becomes simple: every effect must be created inside some scope that someone will stop.

Who stops your watchers? Left: effects created in setup belong to the component scope and stop on unmount. Middle: effects created in a store action or after an await have no active scope and keep running forever, retaining their closures. Right: effects created inside scope.run belong to an explicit effectScope and all stop when scope.stop is called, which also runs onScopeDispose callbacks. Component setup() watch · computed component scope is active unmount → scope.stop() Store action / after await watch · computed no active scope nobody stops them → leak effectScope().run() watch · computed explicit scope is active your teardown → scope.stop()

Step-by-Step Fix

  1. Find effects created outside setup. Search stores, plugins, router guards, services and callbacks for watch(, watchEffect(, computed( and calls to composables that use them. Verification: you have a list of ownerless effect sites.
  2. Check for effects after await in setup. Look for async setup() or <script setup> with top-level await followed by watch/computed or composable calls. Verification: such effects are either moved before the first await or created in a captured scope.
  3. Wrap ownerless effects in a scope. Create const scope = effectScope(true) for the feature or entity, run the setup inside scope.run(() => …), and keep the scope. Verification: the effects are created while the scope is active.
  4. Register cleanup with onScopeDispose. Inside the scope, register non-reactive cleanup (timers, sockets, listeners) with onScopeDispose, so stopping the scope releases everything. Verification: stopping the scope stops timers and closes connections.
  5. Stop the scope at the right moment. Call scope.stop() when the entity is removed, the user logs out, the plugin is uninstalled, or the owning component unmounts. Verification: watchers stop firing (a console.count in a watcher confirms) and snapshots no longer contain their closures.
  6. Measure growth over repetitions. Create and remove the entity or feature many times and compare heap snapshots. Verification: reactive effect objects and their captured data do not accumulate.
Active watchers over 200 thread opens A chat store that creates watchers per thread in an action without a scope accumulates about 600 active watchers after 200 thread opens and closes, each retaining its thread's messages. With one effect scope per thread stopped on close, active watchers stay at 3, the number for the one open thread. 600 0 watchers created in a store action, no scope one effectScope per thread, stopped on close threads opened and closed (0 → 200)

Command and Code Reference

Use case: per-entity scopes in a Pinia store.

// stores/threads.js
import { defineStore } from 'pinia';
import { effectScope, watch, computed, onScopeDispose, ref } from 'vue';

export const useThreads = defineStore('threads', () => {
  const scopes = new Map();                  // threadId → EffectScope
  const unread = ref({});

  function openThread(thread) {
    const scope = effectScope(true);         // detached: lives until we stop it
    scope.run(() => {
      const count = computed(() => thread.messages.filter((m) => !m.read).length);
      watch(count, (n) => { unread.value[thread.id] = n; }, { immediate: true });
      const timer = setInterval(() => thread.refresh(), 30_000);
      onScopeDispose(() => clearInterval(timer));   // non-reactive cleanup
    });
    scopes.set(thread.id, scope);
  }

  function closeThread(id) {
    scopes.get(id)?.stop();                  // stops computed, watch, and the timer
    scopes.delete(id);
    delete unread.value[id];
  }

  return { unread, openThread, closeThread };
});

Use case: a composable that is safe inside and outside components. getCurrentScope() tells you whether someone will stop your effects.

// useVisibility.js
import { ref, getCurrentScope, onScopeDispose } from 'vue';

export function useVisibility() {
  const visible = ref(document.visibilityState === 'visible');
  const onChange = () => { visible.value = document.visibilityState === 'visible'; };
  document.addEventListener('visibilitychange', onChange);

  if (getCurrentScope()) {
    onScopeDispose(() => document.removeEventListener('visibilitychange', onChange));
  } else {
    // Called without a scope: warn in development so the caller wraps it
    if (import.meta.env.DEV) console.warn('useVisibility called outside an effect scope');
  }
  return visible;
}

Verification and Regression Prevention

Verify by repeating the create/remove cycle many times: watchers created for removed entities must stop firing, and heap snapshots should show no growth in reactive effect objects or in the data they captured. In development, the warning from composables called without a scope is an effective tripwire; keep it and treat it as an error in tests.

Establish two conventions: composables register all cleanup with onScopeDispose (never only onUnmounted, which requires a component), and any code that creates effects outside setup() must do so inside an explicit scope owned by a clearly named lifecycle. Combine with store-level cleanup practices from Pinia and Vuex store memory retention.

Per-entity scope lifecycle Create one effectScope when an entity appears, run its watchers and composables inside scope.run, call scope.stop when the entity is removed, and confirm its watchers no longer fire and heap snapshots show no growth in reactive effects. Entity added effectScope() created scope.run() watchers, composables Entity removed scope.stop() Verify watchers silent, no effect growth

Edge Cases and Gotchas

Detached versus attached scopes

effectScope() created inside another active scope is collected by it and stops when the parent stops. Pass true for a detached scope when its lifetime must be independent — then you are responsible for stopping it.

onScopeDispose versus onUnmounted

onUnmounted only works inside a component’s setup. onScopeDispose works in any scope, including component scopes, so composables that use it are portable.

Stopping a scope does not delete data

Stopping the scope stops effects; reactive data referenced elsewhere (for example in a store) remains. Remove the entity’s data from stores when you stop its scope, or it stays retained.

Server-side rendering

During SSR, watchers do not run, but computed values and scopes can still be created per request. Scopes created in request-handling code must be stopped at the end of the request, or they accumulate across requests on the server.

Frequently Asked Questions

What is effectScope in Vue?

An API for grouping reactive effects — watchers, watchEffects and computeds — so they can be stopped together. Effects created while a scope is running are registered with it, and scope.stop() stops all of them and runs any onScopeDispose callbacks.

Why do my watchers keep running after the component is gone?

They were probably created outside the component’s setup scope — for example after an await, in a store action, or in a callback — so the component’s unmount did not stop them. Create them in setup before any await, or inside an explicit effectScope that you stop.

Do computed properties leak memory?

A computed that belongs to a stopped scope is released with it. A computed created without a scope stays active and keeps its dependencies’ subscriptions — and anything it captured — alive. Scope your computeds like your watchers.

How can I tell whether a watcher is still active?

In development, add a console.count or a counter increment inside the watcher callback and trigger its source after the owning feature is gone; any output means it is still active. Vue DevTools also lists component-level effects, and a heap snapshot showing watcher closures retained after teardown confirms the same thing from the memory side.

When should I use a detached scope?

When the effects represent a service or entity whose lifetime is not tied to whatever scope happens to be active when it is created — per-connection, per-document or per-user logic in stores and plugins. Keep a reference and stop it when the entity ends.