Effect Scope in Vue 3: sauberes Cleanup für Composables
AI generated
<v/>
{ }
Vue.js · Reaktivität · Cleanup · Composables
Effect Scope in Vue 3
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.

18 Min. Lesezeit effectScope · onScopeDispose · getCurrentScope Vue 3.2+ · Composables · Pinia

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.

11. FAQ: Effect Scope in Vue 3

1Was ist ein Effect Scope?
Bündelt watch(), watchEffect() und computed() in einer Gruppe, gemeinsam beendbar mit einem stop()-Aufruf.
2Wann statt Component-Cleanup?
Bei Effekten außerhalb von Komponenten: globale Stores, Singleton-Services, Bibliotheks-Composables.
3Was ist ein detached Scope?
effectScope(true), bindet sich nicht an Eltern-Scope, muss unabhängig gestoppt werden.
4Kind-Scopes automatisch gestoppt?
Ja, wenn nicht detached, werden verschachtelte Scopes mit dem Eltern-Scope automatisch gestoppt.
5watch() wird nicht erfasst?
Vermutlich nach await oder in setTimeout angelegt, nur synchrone Effekte werden automatisch registriert.
6Was macht onScopeDispose()?
Registriert Cleanup-Funktion, die beim Stoppen des umgebenden Scopes automatisch läuft.
7Wofür getCurrentScope()?
Gibt aktiven Scope zurück, nutzt Bibliothekscode zur Kontextprüfung vor onScopeDispose().
8Nutzt Pinia Effect Scope?
Ja, jeder Store läuft in eigenem Effect Scope für gebündeltes Cleanup, etwa in Tests.
9Nötig bei einzelnem watch()?
Nein, in Komponenten reicht automatisches Cleanup, Effect Scope lohnt bei mehreren Effekten.
10Mehrfach stoppen möglich?
Ja, wiederholte stop()-Aufrufe sind sicher und wirkungslos bei bereits gestoppten Effekten.