clean cleanup for composables
Anyone who creates several watch, watchEffect, and computed instances inside a composable has to stop each one individually when cleaning up. An Effect Scope groups all of these effects together and ends them with a single stop() call, whether inside or outside a component.
Table of Contents
- 1. Why manual effect cleanup gets messy fast
- 2. The effectScope API: run() and stop()
- 3. Nested scopes and detached mode
- 4. Effect Scope inside your own composable
- 5. Effect Scope for global stores outside components
- 6. getCurrentScope() and onScopeDispose() in library code
- 7. Effect Scope in Pinia and state management libraries
- 8. Common mistakes when using Effect Scope
- 9. Effect Scope compared to manual cleanup
- 10. Summary
- 11. FAQ
1. Why manual effect cleanup gets messy fast
A complex composable often sets up several reactive effects at once: multiple watch() calls, a watchEffect(), and several computed() values. Every watch() call returns a stop function that, in theory, you would have to store individually and call when cleaning up. In practice this quickly becomes error prone once a composable creates new effects across multiple functions and the stop functions end up scattered across the code.
An Effect Scope solves exactly this problem by automatically grouping every reactive effect created inside its run() callback. Instead of managing ten separate stop functions, you call scope.stop() once, and Vue internally ends every single effect that was registered inside that Effect Scope. This drastically reduces the risk of forgotten cleanup calls.
An Effect Scope becomes especially relevant outside the normal component lifecycle. Inside a component, Vue automatically cleans up all effects on unmount, without needing an Effect Scope at all. But as soon as effects are created in a function outside of component setup, for example in a global store or a utility library, that automatic mechanism no longer applies, and an explicit Effect Scope becomes necessary.
2. The effectScope API: run() and stop()
The effectScope() function creates a scope object with two central methods: run(callback) executes the given callback and automatically registers every reactive effect created inside it, meaning watch(), watchEffect(), and computed(), with that scope. stop() ends all registered effects at once, so none of them keeps reacting to changes.
Important: only effects created synchronously inside the run() callback are automatically added to the Effect Scope. If a watch() is created asynchronously after an await inside the callback, it is no longer captured, because Vue determines scope membership through a synchronous execution context. That restriction is a common pitfall in asynchronous composables.
import { effectScope, watch, computed, ref } from 'vue'
const scope = effectScope()
scope.run(() => {
const count = ref(0)
const doubled = computed(() => count.value * 2)
watch(count, (val) => {
console.log('count changed to', val)
})
// both the watch and the computed are now tracked by this scope
})
// later, stop everything registered inside run() with one call
scope.stop()
3. Nested scopes and detached mode
Effect Scopes can be nested: if another effectScope() is created inside the run() callback of an outer Effect Scope, that inner scope is registered as a child scope by default. Stopping the outer scope automatically stops all nested child scopes too, a cascade that fits hierarchical composable structures well, for example when a parent composable orchestrates several sub composables with their own effects.
Sometimes that automatic cascade is unwanted, though. With effectScope(true) you create a so called detached scope, which does not attach itself to a surrounding parent scope. A detached scope must be stopped independently and explicitly, no matter what happens to the surrounding scope. That is useful when a composable needs to start a long lived background process that must outlive the calling component.
import { effectScope, watchEffect } from 'vue'
const parentScope = effectScope()
parentScope.run(() => {
// Nested scope: stops automatically when parentScope stops
const childScope = effectScope()
childScope.run(() => {
watchEffect(() => console.log('child effect running'))
})
// Detached scope: survives parentScope.stop(), must be stopped manually
const detachedScope = effectScope(true)
detachedScope.run(() => {
watchEffect(() => console.log('long-running background task'))
})
})
parentScope.stop() // stops the nested childScope too, NOT the detached one
4. Effect Scope inside your own composable
The practical benefit of an Effect Scope shows most clearly in a composable that watches several data sources at once, for example a WebSocket connection combined with periodic polling. Instead of manually collecting each individual stop function, you encapsulate the entire effect logic inside an Effect Scope and expose a single, clearly named cleanup() function to the outside.
This encapsulation makes the composable more robust to refactoring. If another watch() is added later, you do not need to remember to add its stop function to a cleanup list, because the Effect Scope automatically captures every new effect created inside the run() callback.
import { effectScope, ref, watch, onScopeDispose } from 'vue'
export function useLiveConnection(url) {
const scope = effectScope()
const messages = ref([])
const status = ref('connecting')
scope.run(() => {
const socket = new WebSocket(url)
socket.onopen = () => { status.value = 'connected' }
socket.onmessage = (event) => { messages.value.push(event.data) }
watch(status, (newStatus) => {
console.log('connection status:', newStatus)
})
// Runs automatically when the scope is stopped
onScopeDispose(() => {
socket.close()
})
})
function cleanup() {
scope.stop() // stops the watch AND closes the socket via onScopeDispose
}
return { messages, status, cleanup }
}
5. Effect Scope for global stores outside components
A central use case for Effect Scope is global stores that live outside the normal component lifecycle, for example a singleton store for authentication status initialized once at app startup. Since there is no component unmount to automatically clean up here, the store itself needs to create an Effect Scope so it can be reset cleanly when needed, for example in tests or during hot module replacement.
Without an Effect Scope, every hot reload during development would create new watch() instances without ever stopping the old ones, a classic memory leak that shows up as increasingly slow reloads in the development environment. With an explicit Effect Scope, the store can call scope.stop() before every reinitialization, guaranteeing a clean state.
import { effectScope, reactive, watch } from 'vue'
// Global store living outside any component lifecycle
let scope
let state
export function createAuthStore() {
scope?.stop() // clean up previous instance before re-creating (e.g. HMR)
scope = effectScope(true) // detached: not tied to any component
scope.run(() => {
state = reactive({ user: null, token: null })
watch(() => state.token, (token) => {
if (token) localStorage.setItem('auth_token', token)
})
})
return state
}
6. getCurrentScope() and onScopeDispose() in library code
For authors of composable libraries, getCurrentScope() is essential: the function returns the currently active Effect Scope if one exists, or undefined if the code runs outside of a scope. This lets a library function check whether it can meaningfully register cleanup hooks before calling onScopeDispose(), which would otherwise trigger a warning.
onScopeDispose() registers a cleanup function that runs automatically once the surrounding Effect Scope is stopped, regardless of whether that scope belongs to a component or was created standalone. That makes onScopeDispose() the more general, scope agnostic alternative to onUnmounted(), which only works inside components.
7. Effect Scope in Pinia and state management libraries
Pinia uses Effect Scope internally for every single store: every store created with defineStore() runs in its own Effect Scope, so all computed getters and internal watch() calls of a store can be ended together once the store is no longer needed, for example when testing with several isolated Pinia instances. Without this mechanism, tests that create a fresh Pinia instance per test case would leave old store effects running and interfering with each other.
If you build your own state management solution beyond Pinia, you can adopt the same pattern: every store instance gets its own Effect Scope, and a $dispose() method calls scope.stop() internally. That makes your own stores just as cleanly isolatable in test environments as Pinia stores, without having to reinvent the wheel.
8. Common mistakes when using Effect Scope
The most common mistake is creating effects outside the synchronous run() callback, for example after an await or inside a setTimeout. These effects are not captured by the Effect Scope and keep happily running after a stop() call, undermining the whole point of the scope without any error message pointing it out.
import { effectScope, watch, ref } from 'vue'
const scope = effectScope()
const data = ref(null)
scope.run(async () => {
const response = await fetch('/api/data')
data.value = await response.json()
// WRONG: this watch is created after an await,
// outside the synchronous scope tracking window
watch(data, (val) => console.log(val))
})
// scope.stop() will NOT stop the watch above — it was never tracked
A second mistake is never stopping an Effect Scope because you assume Vue will clean it up automatically. Inside a component that is true for effects created directly in setup, but a scope explicitly created with effectScope() only stops automatically if it runs inside a component and is not detached. For detached scopes and scopes outside of components, stop() is always the developer's responsibility.
9. Effect Scope compared to manual cleanup
The overview below shows the practical difference between manual cleanup and using an Effect Scope for typical composable scenarios.
| Scenario | Manual cleanup | With Effect Scope | Benefit |
|---|---|---|---|
| Multiple watch() in one composable | Collect every stop function individually | scope.stop() ends all of them | No forgotten stop calls |
| Store outside components | No automatic unmount cleanup | Detached scope with manual stop() | Clean reset for HMR and tests |
| Nested sub composables | Wire up the cleanup chain manually | Child scopes stopped automatically | Cascade without extra code |
| Single watch() in a component | onUnmounted() is enough | Unnecessary overhead | Effect Scope only worth it with multiple effects |
| Library composable | Must assume a component context | getCurrentScope() checks context | Works inside and outside components |
Effect Scope pays off especially once more than one reactive effect needs to be cleaned up together, or once effects arise outside the normal component lifecycle. For a single watch() inside a component, Vue's built in automatic cleanup logic is entirely sufficient.
Mironsoft
Vue 3 composables without memory leaks
Are your Vue apps getting slower over time?
We find effects that never get cleaned up in your composables and global stores, build proper Effect Scope cleanup, and prevent memory leaks in production.
Memory leak audit
Checking composables and stores for missing cleanup
Effect Scope refactoring
Moving global stores and composables to bundled cleanup
HMR and test stability
Clean reinitialization during hot reload and test runs
10. Summary
An Effect Scope groups multiple reactive effects, watch(), watchEffect(), and computed(), into one unit and lets you end all of them with a single stop() call. That is especially valuable outside the normal component lifecycle, for example for global stores, singleton services, or library composables, where Vue does not handle automatic unmount cleanup. Nested scopes cascade automatically, and effectScope(true) creates a detached scope that lives independently of its surrounding context.
The biggest pitfall remains creating effects outside the synchronous run() callback, for example after an await, because those are then not captured by the Effect Scope. getCurrentScope() and onScopeDispose() are the right tools for library code that has to work both inside and outside of components, and that exact pattern is what Pinia uses internally for every single store.
Effect Scope in Vue 3 — The Essentials at a Glance
Core principle
effectScope() groups effects, run() registers them, stop() ends all of them at once.
Detached scope
effectScope(true) detaches the scope from its parent context, must always be stopped manually.
Most common mistake
Creating effects after an await inside the run() callback, they are never captured.
Real world example
Pinia uses Effect Scope internally for every single store, for clean test cleanup.