sauberes Cleanup für Composables
Wer in einem Composable mehrere watch, watchEffect und computed anlegt, muss beim Aufräumen jeden einzeln stoppen. Ein Effect Scope bündelt alle diese Effekte in einer Gruppe und beendet sie mit einem einzigen stop() Aufruf, ganz gleich ob innerhalb oder außerhalb einer Komponente.
Inhaltsverzeichnis
- 1. Warum manuelles Effekt-Cleanup schnell unübersichtlich wird
- 2. Die effectScope API: run() und stop()
- 3. Verschachtelte Scopes und der detached-Modus
- 4. Effect Scope in einem eigenen Composable
- 5. Effect Scope für globale Stores außerhalb von Komponenten
- 6. getCurrentScope() und onScopeDispose() in Bibliothekscode
- 7. Effect Scope in Pinia und State-Management-Libraries
- 8. Häufige Fehler beim Einsatz von Effect Scope
- 9. Effect Scope im Vergleich zu manuellem Cleanup
- 10. Zusammenfassung
- 11. FAQ
1. Warum manuelles Effekt-Cleanup schnell unübersichtlich wird
Ein komplexes Composable legt häufig mehrere reaktive Effekte gleichzeitig an: mehrere watch()-Aufrufe, ein watchEffect() und mehrere computed()-Werte. Jeder watch()-Aufruf gibt eine Stop-Funktion zurück, die man theoretisch einzeln speichern und beim Aufräumen aufrufen müsste. In der Praxis wird das schnell fehleranfällig, sobald ein Composable über mehrere Funktionen hinweg neue Effekte anlegt und die Stop-Funktionen an mehreren Stellen im Code verstreut landen.
Ein Effect Scope löst genau dieses Problem, indem er alle innerhalb seines run()-Callbacks erzeugten reaktiven Effekte automatisch in einer Gruppe sammelt. Statt zehn einzelne Stop-Funktionen zu verwalten, ruft man einmal scope.stop() auf, und Vue beendet intern jeden einzelnen Effekt, der innerhalb dieses Effect Scope registriert wurde. Das reduziert die Fehlerquelle vergessener Cleanup-Aufrufe drastisch.
Besonders relevant wird ein Effect Scope außerhalb des normalen Komponenten-Lifecycles. Innerhalb einer Komponente räumt Vue automatisch alle Effekte beim Unmount auf, ganz ohne Effect Scope. Sobald Effekte aber in einer Funktion außerhalb des Component-Setups erzeugt werden, etwa in einem globalen Store oder einer Utility-Bibliothek, greift dieser automatische Mechanismus nicht mehr, und ein expliziter Effect Scope wird notwendig.
2. Die effectScope API: run() und stop()
Die Funktion effectScope() erzeugt ein Scope-Objekt mit zwei zentralen Methoden: run(callback) führt den übergebenen Callback aus und registriert jeden darin erzeugten reaktiven Effekt, also watch(), watchEffect() und computed(), automatisch bei diesem Scope. stop() beendet alle registrierten Effekte gleichzeitig, sodass keiner von ihnen weiterhin auf Änderungen reagiert.
Wichtig: Nur Effekte, die synchron innerhalb des run()-Callbacks erzeugt werden, landen automatisch im Effect Scope. Wird ein watch() asynchron nach einem await innerhalb des Callbacks angelegt, wird er nicht mehr erfasst, da Vue die Scope-Zugehörigkeit über einen synchronen Ausführungskontext bestimmt. Diese Einschränkung ist ein häufiger Stolperstein bei asynchronen 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. Verschachtelte Scopes und der detached-Modus
Effect Scopes können verschachtelt werden: Wird innerhalb des run()-Callbacks eines äußeren Effect Scope ein weiterer effectScope() erzeugt, wird dieser innere Scope standardmäßig als Kind-Scope registriert. Stoppt man den äußeren Scope, werden automatisch auch alle verschachtelten Kind-Scopes gestoppt, eine Kaskade, die sich für hierarchische Composable-Strukturen anbietet, etwa wenn ein übergeordnetes Composable mehrere Sub-Composables mit eigenen Effekten orchestriert.
Manchmal ist diese automatische Kaskade aber unerwünscht. Mit effectScope(true) erzeugt man einen sogenannten detached scope, der sich nicht an einen umgebenden Eltern-Scope bindet. Ein detached Scope muss unabhängig und explizit gestoppt werden, unabhängig davon, was mit dem umgebenden Scope passiert. Das ist nützlich, wenn ein Composable einen langlebigen Hintergrundprozess starten soll, der die Lebensdauer der aufrufenden Komponente überdauern muss.
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 in einem eigenen Composable
Der praktische Nutzen eines Effect Scope zeigt sich am deutlichsten in einem Composable, das mehrere Datenquellen gleichzeitig beobachtet, etwa eine WebSocket-Verbindung kombiniert mit periodischem Polling. Statt jede einzelne Stop-Funktion manuell zu sammeln, kapselt man die komplette Effekt-Logik in einem Effect Scope und gibt eine einzige, klar benannte cleanup()-Funktion nach außen.
Diese Kapselung macht das Composable robuster gegenüber Refactorings. Wird später ein weiterer watch() hinzugefügt, muss man nicht daran denken, dessen Stop-Funktion zusätzlich in die Cleanup-Liste aufzunehmen, weil der Effect Scope automatisch jeden neuen Effekt innerhalb des run()-Callbacks miterfasst.
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 für globale Stores außerhalb von Komponenten
Ein zentraler Anwendungsfall für Effect Scope sind globale Stores, die außerhalb des normalen Komponenten-Lifecycles leben, etwa ein Singleton-Store für Authentifizierungsstatus, der beim App-Start einmal initialisiert wird. Da hier kein Component-Unmount existiert, der automatisch aufräumt, muss der Store selbst einen Effect Scope anlegen, um bei Bedarf, etwa in Tests oder bei Hot Module Replacement, sauber zurückgesetzt werden zu können.
Ohne Effect Scope würden bei jedem Hot-Reload während der Entwicklung neue watch()-Instanzen entstehen, ohne dass die alten jemals gestoppt werden, ein klassischer Memory Leak, der sich in der Entwicklungsumgebung durch immer langsamer werdende Reloads bemerkbar macht. Mit einem expliziten Effect Scope kann der Store vor jeder Neuinitialisierung scope.stop() aufrufen und garantiert damit einen sauberen Zustand.
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() und onScopeDispose() in Bibliothekscode
Für Autoren von Composable-Bibliotheken ist getCurrentScope() unverzichtbar: Die Funktion gibt den aktuell aktiven Effect Scope zurück, falls einer existiert, oder undefined, wenn der Code außerhalb eines Scope ausgeführt wird. Damit kann eine Bibliotheksfunktion prüfen, ob sie sinnvoll Cleanup-Hooks registrieren kann, bevor sie onScopeDispose() aufruft, was sonst zu einer Warnung führen würde.
onScopeDispose() registriert eine Cleanup-Funktion, die automatisch läuft, sobald der umgebende Effect Scope gestoppt wird, unabhängig davon, ob dieser Scope zu einer Komponente gehört oder eigenständig erzeugt wurde. Das macht onScopeDispose() zur allgemeineren, Scope-agnostischen Alternative zu onUnmounted(), die ausschließlich innerhalb von Komponenten funktioniert.
7. Effect Scope in Pinia und State-Management-Libraries
Pinia nutzt Effect Scope intern für jeden einzelnen Store: Jeder mit defineStore() erzeugte Store läuft in seinem eigenen Effect Scope, sodass alle computed-Getter und internen watch()-Aufrufe eines Stores gebündelt beendet werden können, wenn der Store nicht mehr gebraucht wird, etwa beim Testen mit mehreren isolierten Pinia-Instanzen. Ohne diesen Mechanismus würden in Tests, die für jeden Testfall eine neue Pinia-Instanz erzeugen, alte Store-Effekte weiterlaufen und sich gegenseitig stören.
Wer eigene State-Management-Lösungen jenseits von Pinia baut, kann dasselbe Muster übernehmen: Jede Store-Instanz bekommt ihren eigenen Effect Scope, und eine $dispose()-Methode ruft intern scope.stop() auf. Das macht eigene Stores in Testumgebungen genauso sauber isolierbar wie Pinia-Stores, ohne dass man das Rad neu erfinden muss.
8. Häufige Fehler beim Einsatz von Effect Scope
Der häufigste Fehler ist, Effekte außerhalb des synchronen run()-Callbacks anzulegen, etwa nach einem await oder in einem setTimeout. Diese Effekte werden nicht vom Effect Scope erfasst und laufen nach einem stop()-Aufruf munter weiter, was den ganzen Zweck des Scopes untergräbt, ohne dass eine Fehlermeldung darauf hinweist.
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
Ein zweiter Fehler ist, einen Effect Scope niemals zu stoppen, weil man annimmt, Vue würde ihn automatisch aufräumen. Innerhalb einer Komponente stimmt das für Effekte, die direkt im Setup erzeugt werden, aber ein explizit mit effectScope() erzeugter Scope wird nur automatisch gestoppt, wenn er innerhalb einer Komponente läuft und nicht detached ist. Bei detached Scopes und bei Scopes außerhalb von Komponenten ist stop() immer die Verantwortung des Entwicklers.
9. Effect Scope im Vergleich zu manuellem Cleanup
Die folgende Übersicht zeigt den praktischen Unterschied zwischen manuellem Cleanup und dem Einsatz eines Effect Scope für typische Composable-Szenarien.
| Szenario | Manuelles Cleanup | Mit Effect Scope | Vorteil |
|---|---|---|---|
| Mehrere watch() in einem Composable | Jede Stop-Funktion einzeln sammeln | scope.stop() beendet alle | Keine vergessenen Stop-Aufrufe |
| Store außerhalb von Komponenten | Kein automatisches Unmount-Cleanup | Detached Scope mit manuellem stop() | Sauberer Reset bei HMR und Tests |
| Verschachtelte Sub-Composables | Cleanup-Kette manuell verdrahten | Kind-Scopes automatisch gestoppt | Kaskade ohne zusätzlichen Code |
| Einzelner watch() in Komponente | onUnmounted() reicht | Unnötiger Overhead | Effect Scope nur bei mehreren Effekten sinnvoll |
| Bibliotheks-Composable | Muss Component-Kontext annehmen | getCurrentScope() prüft Kontext | Funktioniert innerhalb und außerhalb Komponenten |
Effect Scope lohnt sich vor allem, sobald mehr als ein reaktiver Effekt zusammen aufgeräumt werden muss oder sobald Effekte außerhalb des normalen Komponenten-Lifecycles entstehen. Für einen einzelnen watch() innerhalb einer Komponente reicht die eingebaute automatische Cleanup-Logik von Vue vollkommen aus.
Mironsoft
Vue 3 Composables ohne Memory Leaks
Werden eure Vue-Apps über Zeit langsamer?
Wir finden nicht aufgeräumte Effekte in euren Composables und globalen Stores, bauen sauberes Effect-Scope-Cleanup ein und verhindern Memory Leaks in Produktion.
Memory-Leak-Audit
Composables und Stores auf fehlendes Cleanup prüfen
Effect-Scope-Refactoring
Globale Stores und Composables auf gebündeltes Cleanup umstellen
HMR- und Test-Stabilität
Saubere Neuinitialisierung bei Hot Reload und Testläufen
10. Zusammenfassung
Ein Effect Scope bündelt mehrere reaktive Effekte, watch(), watchEffect() und computed(), in einer Gruppe und erlaubt es, alle mit einem einzigen stop()-Aufruf zu beenden. Das ist besonders wertvoll außerhalb des normalen Komponenten-Lifecycles, etwa für globale Stores, Singleton-Services oder Bibliotheks-Composables, wo Vue kein automatisches Unmount-Cleanup übernimmt. Verschachtelte Scopes kaskadieren automatisch, effectScope(true) erzeugt einen detached Scope, der unabhängig vom umgebenden Kontext lebt.
Der größte Stolperstein bleibt, Effekte außerhalb des synchronen run()-Callbacks anzulegen, etwa nach einem await, weil diese dann nicht vom Effect Scope erfasst werden. getCurrentScope() und onScopeDispose() sind die richtigen Werkzeuge für Bibliothekscode, das sowohl innerhalb als auch außerhalb von Komponenten funktionieren muss, und genau dieses Muster nutzt auch Pinia intern für jeden einzelnen Store.
Effect Scope in Vue 3 — Das Wichtigste auf einen Blick
Grundprinzip
effectScope() bündelt Effekte, run() registriert sie, stop() beendet alle auf einmal.
Detached Scope
effectScope(true) löst den Scope vom Eltern-Kontext, muss immer manuell gestoppt werden.
Häufigster Fehler
Effekte nach einem await im run()-Callback anlegen — werden nicht erfasst.
Praxisbeispiel
Pinia nutzt Effect Scope intern für jeden einzelnen Store, für sauberes Test-Cleanup.