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.
Step-by-Step Fix
- 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. - Check for effects after
awaitin setup. Look forasync setup()or<script setup>with top-levelawaitfollowed bywatch/computedor composable calls. Verification: such effects are either moved before the firstawaitor created in a captured scope. - Wrap ownerless effects in a scope. Create
const scope = effectScope(true)for the feature or entity, run the setup insidescope.run(() => …), and keep the scope. Verification: the effects are created while the scope is active. - Register cleanup with
onScopeDispose. Inside the scope, register non-reactive cleanup (timers, sockets, listeners) withonScopeDispose, so stopping the scope releases everything. Verification: stopping the scope stops timers and closes connections. - 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 (aconsole.countin a watcher confirms) and snapshots no longer contain their closures. - 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.
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.
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.
Related
- Vue Reactivity and Memory Management — the parent topic
- Preventing Memory Leaks in Vue Watchers and Computed — lifecycle rules inside components
- Vue Event Bus Listener Leaks — non-reactive subscriptions that need the same care
- Framework-Specific Memory Optimization — the section overview