Vue Composables mit AbortController sauber abbrechbar machen
AI generated
{ }
Vue 3 · Composables · Fetch
Composables sauber abbrechbar machen
AbortController gegen hängende Requests und Race Conditions

Ein Composable, das Daten lädt, aber laufende Requests nicht abbrechen kann, produziert schleichend zwei Arten von Problemen: veraltete Antworten, die nach dem Unmount noch versuchen, einen längst zerstörten Zustand zu aktualisieren, und Race Conditions, bei denen ein schneller zweiter Request von einem langsameren ersten überschrieben wird. AbortController löst beide Probleme, wenn man ihn konsequent in das eigene useFetch-Composable integriert.

15 Min. Lesezeit AbortController useFetch

1. Warum ein laufender Request beim Unmount abgebrochen werden sollte

Wird eine Komponente entfernt, während ein von ihr ausgelöster Fetch-Request noch läuft, läuft der Netzwerkaufruf im Browser trotzdem bis zum Ende weiter, weil ein normales fetch-Promise keine Verbindung zum Lebenszyklus einer Vue-Komponente kennt. Kommt die Antwort schließlich an, versucht der then-Handler, einen ref zu aktualisieren, der zu einer bereits verworfenen Komponenteninstanz gehört. In Vue 3 führt das zwar meist nicht zu einem harten Fehler, weil der ref selbst weiterhin existiert, aber es ist verschwendete Arbeit und in Kombination mit weiteren Seiteneffekten wie Toasts oder Navigationen ein reales Risiko für inkonsistentes Verhalten.

Problematischer wird es bei Listen- und Suchansichten, in denen Nutzer schnell zwischen verschiedenen Filtern oder Detailseiten wechseln. Jeder Wechsel löst einen neuen Request aus, aber ohne Abbruchmechanismus laufen alle bisherigen Requests im Hintergrund weiter und können in beliebiger Reihenfolge antworten. Ein Composable, das nicht abbricht, hat also nicht nur ein Aufräumproblem beim Unmount, sondern auch ein strukturelles Problem bei jeder erneuten Nutzung während der Komponente noch gemountet ist.

2. AbortController Grundlagen

Die Fetch API unterstützt seit Langem ein signal-Argument, das man aus einem AbortController bezieht und an die Optionen von fetch übergibt. Ruft man anschließend controller.abort() auf, wechselt das zugehörige Promise nicht in den Erfolgs-, sondern in den Fehlerzustand, und zwar mit einem Fehler, dessen name-Eigenschaft den Wert AbortError trägt. Der Browser bricht dabei die zugrunde liegende Netzwerkverbindung tatsächlich ab, es handelt sich also nicht nur um ein ignoriertes Promise, sondern um einen echten Verbindungsabbruch, der auch Bandbreite spart.

Ein einzelner AbortController kann für genau einen Abbruchvorgang verwendet werden; nach dem Aufruf von abort() ist er verbraucht und muss für den nächsten Request durch eine frische Instanz ersetzt werden. Diese Einweg-Natur ist wichtig für das Design eines Composables, weil man den Controller nicht wiederverwenden, sondern bei jedem neuen Request neu erzeugen und die Referenz auf den vorherigen Controller entsprechend austauschen muss.


// Reines Beispiel ohne Vue: AbortController mit fetch
const controller = new AbortController()

fetch('/api/products', { signal: controller.signal })
  .then((response) => response.json())
  .catch((error) => {
    if (error.name === 'AbortError') {
      console.log('Request wurde abgebrochen')
      return
    }
    throw error
  })

// Irgendwo anders im Code, z.B. beim Unmount:
controller.abort()

3. Ein eigenes useFetch-Composable bauen

Ein minimales, abbrechbares useFetch-Composable hält neben data, error und loading auch eine Referenz auf den aktuell aktiven AbortController. Die eigentliche execute-Funktion erzeugt bei jedem Aufruf zunächst einen neuen Controller, übergibt dessen signal an fetch, und speichert den Controller in einer Modul- oder Closure-Variable, damit er später von außen abgebrochen werden kann. Der Vorteil eines eigenen Composables gegenüber einem direkten fetch-Aufruf in der Komponente ist, dass diese gesamte Abbruchlogik an einer Stelle gekapselt bleibt und in jeder Komponente, die Daten lädt, wiederverwendet werden kann.

Wichtig ist, dass das Composable seinen eigenen internen Zustand konsequent zurücksetzt, wenn ein neuer Request gestartet wird, damit loading während eines laufenden Requests korrekt true bleibt und error von einem vorherigen fehlgeschlagenen Versuch nicht fälschlich weiter angezeigt wird. Diese Detailarbeit fällt in der Praxis leicht unter den Tisch, wenn man Fetch-Logik ad hoc in einzelnen Komponenten dupliziert, während sie in einem zentralen Composable nur einmal richtig geschrieben werden muss.


// composables/useFetch.ts
import { ref, shallowRef } from 'vue'

export function useFetch<T>(url: string) {
  const data = shallowRef<T | null>(null)
  const error = shallowRef<Error | null>(null)
  const loading = ref(false)

  let controller: AbortController | null = null

  async function execute() {
    controller?.abort()
    controller = new AbortController()

    loading.value = true
    error.value = null

    try {
      const response = await fetch(url, { signal: controller.signal })
      if (!response.ok) throw new Error(`HTTP ${response.status}`)
      data.value = await response.json()
    } catch (err) {
      if ((err as Error).name === 'AbortError') return
      error.value = err as Error
    } finally {
      loading.value = false
    }
  }

  function cancel() {
    controller?.abort()
  }

  return { data, error, loading, execute, cancel }
}

4. Automatisches Abbrechen bei erneutem Aufruf

Der entscheidende Satz im Composable oben ist controller?.abort() ganz am Anfang von execute. Wird execute während eines noch laufenden Requests erneut aufgerufen, etwa weil der Nutzer schnell zwischen zwei Suchbegriffen wechselt, bricht diese Zeile den vorherigen Request sofort ab, bevor der neue gestartet wird. Dadurch kann die Antwort des alten Requests niemals mehr die Antwort des neuen überschreiben, selbst wenn der Server aus irgendeinem Grund für die ältere Anfrage länger braucht als für die neuere.

Ohne diesen Mechanismus entsteht eine klassische Race Condition: Request A startet, kurz danach startet Request B, aber Request A antwortet aus welchen Gründen auch immer später als Request B. Ohne Abbruch überschreibt die später ankommende, aber eigentlich veraltete Antwort von Request A den bereits korrekt gesetzten Zustand von Request B, und der Nutzer sieht kurzzeitig oder dauerhaft falsche Daten. Der abort()-Aufruf zu Beginn jedes execute macht dieses Szenario strukturell unmöglich, weil eine abgebrochene Anfrage niemals mehr einen then-Zweig erreicht.

5. Abbrechen beim Unmount über onUnmounted

Damit ein Composable auch beim Verlassen der aufrufenden Komponente sauber aufräumt, registriert es intern einen onUnmounted-Hook, der die cancel-Funktion aufruft. Wichtig dabei ist, dass onUnmounted innerhalb des Composables selbst aufgerufen wird und nicht erst in der Komponente, die das Composable verwendet, denn nur so ist garantiert, dass die Abbruchlogik automatisch mitkommt, ganz ohne dass jede aufrufende Komponente daran denken muss, sie manuell hinzuzufügen.

Für Composables, die auch außerhalb einer Komponenteninstanz laufen können, etwa innerhalb eines Pinia-Stores, ist onScopeDispose die passendere Wahl, weil es an den effectScope statt an eine konkrete Komponente gebunden ist. In den meisten Anwendungsfällen innerhalb von script setup verhalten sich beide praktisch gleich, aber onScopeDispose funktioniert auch dort noch, wo onUnmounted mangels umgebender Komponente eine Warnung ausgeben würde.

6. Fehlerbehandlung: AbortError von echten Fehlern unterscheiden

Ein häufiger Fehler ist, im catch-Block jeden gefangenen Fehler gleich zu behandeln und dem Nutzer eine Fehlermeldung anzuzeigen, selbst wenn der Request nur bewusst abgebrochen wurde. Ein abgebrochener Request ist kein echter Fehlerfall im fachlichen Sinn, sondern ein gewolltes Verhalten des eigenen Codes, und sollte deshalb im catch-Block explizit erkannt und stillschweigend ignoriert werden, indem man die name-Eigenschaft des gefangenen Fehlers auf den Wert AbortError prüft.

Erst nach dieser Prüfung sollte der error-Ref des Composables gesetzt und damit eine UI-Fehlermeldung ausgelöst werden. Ohne diese Unterscheidung sehen Nutzer bei schnellem Tippen in einem Suchfeld ständig kurz aufblitzende Fehlermeldungen für Requests, die technisch gar nicht fehlgeschlagen sind, sondern lediglich vom nächsten, aktuelleren Request verdrängt wurden.

7. Abbrechen bei Änderung reaktiver Parameter

Hängt die URL oder ein Suchparameter von einem reaktiven Wert ab, kombiniert man das Composable typischerweise mit einem watch, der execute bei jeder Änderung erneut aufruft. Weil execute intern bereits den vorherigen Controller abbricht, muss der watch selbst keine zusätzliche Abbruchlogik enthalten, er ruft einfach bei jeder Änderung erneut execute auf, und das Composable sorgt dafür, dass immer nur der jeweils letzte Request tatsächlich zu einer sichtbaren Zustandsänderung führt.

Ein watch mit der Option immediate true führt dabei den ersten Aufruf sofort beim Setup der Komponente aus, während jede weitere Änderung des beobachteten Werts automatisch einen neuen, den vorherigen ersetzenden Request auslöst. Dieses Muster lässt sich noch weiter verfeinern, indem man den watch mit einem kurzen Debounce kombiniert, sodass bei sehr schnellen aufeinanderfolgenden Änderungen, etwa bei jedem Tastendruck in einem Suchfeld, nicht bei jedem einzelnen Zeichen ein neuer Request gestartet wird.

8. Timeout mit AbortController kombinieren

Neben dem manuellen Abbrechen bei Unmount oder erneutem Aufruf lässt sich derselbe AbortController auch für ein Timeout nutzen, ohne dass man dafür eine zweite, parallele Abbruchmechanik einführen müsste. Moderne Browser bieten dafür AbortSignal.timeout(ms) an, das ein fertiges Signal zurückgibt, das nach der angegebenen Zeit automatisch abbricht, oder man kombiniert mehrere Signale über AbortSignal.any() mit dem eigenen, manuell steuerbaren Controller-Signal.

Fehlt AbortSignal.any() im Zielbrowser-Support, lässt sich derselbe Effekt auch mit einem einfachen setTimeout erreichen, das nach Ablauf der Zeit controller.abort() auf demselben Controller aufruft, der auch für manuelle Abbrüche verwendet wird. In beiden Fällen bleibt die Fehlerbehandlung im catch-Block unverändert, weil ein durch Timeout ausgelöster Abbruch denselben AbortError erzeugt wie ein manueller Abbruch, und beide Fälle können bei Bedarf über die reason-Eigenschaft des Signals weiter unterschieden werden.

9. Abbrechbare Composables testen

Beim Testen eines abbrechbaren Composables lohnt es sich, gezielt drei Szenarien abzudecken: einen normal durchlaufenden Request, einen Request, der durch einen zweiten Aufruf während des Laufs abgebrochen wird, und einen Request, dessen Komponente vor der Antwort unmountet wird. Mit einer gemockten fetch-Implementierung, die auf das signal-Argument reagiert und bei einem Abort das Promise mit einem AbortError ablehnt, lassen sich alle drei Fälle deterministisch in Unit-Tests nachstellen, ohne auf echte Netzwerk-Latenz angewiesen zu sein.

Besonders wertvoll ist ein Test, der explizit prüft, dass nach einem erneuten Aufruf von execute während eines laufenden Requests am Ende genau der Zustand des zweiten, nicht des ersten Requests im data-Ref landet. Dieser Test bildet die eigentliche Race-Condition-Vermeidung ab und fängt Regressionsfehler zuverlässig ab, falls jemand die abort()-Zeile am Anfang von execute versehentlich entfernt oder falsch platziert.

Szenario Ohne AbortController Mit AbortController im Composable Effekt
Komponente wird unmounted Request läuft im Hintergrund weiter onUnmounted ruft cancel() auf Kein Update auf verworfenem Zustand
Zweiter Aufruf während erster läuft Race Condition möglich Vorheriger Controller wird zuerst abgebrochen Nur die neueste Antwort zählt
Nutzer tippt schnell in Suchfeld Viele parallele, unnötige Requests Jeder neue Tastendruck bricht den vorherigen ab Weniger Netzwerklast, konsistenter Zustand
Server antwortet sehr langsam UI wartet unbegrenzt AbortSignal.timeout() bricht nach Frist ab Vorhersehbares Fehlerverhalten

Mironsoft

Vue-Architektur, Composition API und Nuxt-Performance

Vue-Anwendungen, die mit jedem Feature nicht komplizierter werden?

Wir prüfen bestehende Vue- und Nuxt-Projekte auf unstrukturierte Composables, ungenutzte Reaktivität und aufgeblähte Bundles und bauen daraus eine Architektur, die neue Features aufnimmt, ohne die Codebasis unübersichtlicher zu machen.

Architektur-Review

Composables, State-Management und Komponentenstruktur auf Wartbarkeit prüfen.

Performance-Audit

Reaktivitäts-Overhead, Bundle-Größe und Nuxt-Rendering-Strategie systematisch optimieren.

Nuxt-Integration

SSR/SSG-Setup und API-Anbindung robust und typsicher aufbauen.

10. Zusammenfassung

Abbrechbare Vue-Composables mit AbortController: Das Wichtigste auf einen Blick

Kernproblem

Laufende Requests überleben Unmount und überschreiben neuere Antworten

Lösung

AbortController pro Request, alter Controller wird vor neuem Aufruf abgebrochen

Cleanup

onUnmounted beziehungsweise onScopeDispose ruft cancel() automatisch auf

Fehlerbehandlung

AbortError explizit erkennen und von echten Fehlern trennen

11. FAQ: Abbrechbare Vue-Composables mit AbortController: Das Wichtigste auf einen Blick

1Warum sollte ein Fetch-Request beim Unmount einer Komponente abgebrochen werden?
Ohne Abbruch läuft der Request im Browser weiter und versucht später, einen ref zu aktualisieren, der zu einer bereits verworfenen Komponente gehört. Das verschwendet Netzwerkbandbreite und kann bei weiteren Seiteneffekten zu inkonsistentem Verhalten führen.
2Was ist der Unterschied zwischen einem AbortError und einem echten Netzwerkfehler?
Ein AbortError entsteht ausschließlich dadurch, dass der eigene Code controller.abort() aufgerufen hat, während ein echter Fehler etwa durch ein fehlgeschlagenes HTTP-Statuscode oder eine unterbrochene Verbindung ausgelöst wird. Beide landen im catch-Block, sollten aber unterschiedlich behandelt werden.
3Kann ich einen AbortController für mehrere Requests wiederverwenden?
Nein. Ein AbortController ist nach dem Aufruf von abort() verbraucht und muss für jeden neuen Request durch eine frische Instanz ersetzt werden.
4Wie verhindert AbortController Race Conditions bei schnell wechselnden Anfragen?
Indem jeder neue Aufruf des Composables zuerst den Controller des vorherigen, noch laufenden Requests abbricht. Eine abgebrochene Anfrage erreicht nie mehr einen erfolgreichen then-Zweig, sodass nur die Antwort der zuletzt gestarteten Anfrage tatsächlich Zustand setzt.
5Sollte ich onUnmounted innerhalb des Composables oder in der Komponente aufrufen?
Innerhalb des Composables. Nur so ist garantiert, dass jede Komponente, die das Composable nutzt, automatisch von der Abbruchlogik profitiert, ohne selbst daran denken zu müssen.
6Wann ist onScopeDispose besser als onUnmounted?
Wenn das Composable auch außerhalb einer konkreten Komponenteninstanz laufen kann, etwa in einem Pinia-Store, weil onScopeDispose an den effectScope statt an eine Komponente gebunden ist.
7Wie kombiniere ich einen Timeout mit AbortController?
Über AbortSignal.timeout(ms) für ein fertiges, sich selbst abbrechendes Signal, oder über AbortSignal.any() in Kombination mit dem eigenen manuellen Controller-Signal, falls Browser-Support fehlt auch über einen einfachen setTimeout, der abort() aufruft.
8Muss jede Komponente selbst prüfen, ob ein Fehler ein AbortError ist?
Nein, diese Prüfung gehört in das Composable selbst. Die Komponente bekommt über den error-Ref nur echte, fachlich relevante Fehler zu sehen, während Abbrüche intern stillschweigend behandelt werden.
9Funktioniert dieses Muster auch mit Axios statt der nativen Fetch API?
Ja, Axios unterstützt seit Version 0.22 ebenfalls das signal-Argument aus AbortController, sodass sich dasselbe Composable-Muster mit minimalen Anpassungen auch dort einsetzen lässt.
10Wie teste ich, dass die Race-Condition-Vermeidung wirklich funktioniert?
Mit einer gemockten fetch-Implementierung, die zwei Requests unterschiedlich schnell auflöst, und einer Assertion, dass am Ende der Zustand des zuletzt gestarteten, nicht des zuerst gestarteten Requests im data-Ref steht.