Race Conditions in useEffect mit AbortController vermeiden | Mironsoft
AI generated
{ }
React 19 · Hooks · Datenabruf
Race Conditions in useEffect
mit AbortController zuverlässig vermeiden

Wenn Nutzer schnell zwischen Suchbegriffen, Filtern oder Tabs wechseln, feuert useEffect mehrere Requests hintereinander ab. Trifft die Antwort auf die alte Anfrage später ein als die Antwort auf die neue, landet veralteter Content im UI. AbortController löst dieses Problem strukturell statt es mit Flags zu kaschieren.

15 Min. Lesezeit AbortController Cleanup-Function Fetch API

1. Warum Race Conditions in useEffect so leicht entstehen

Eine Race Condition entsteht in useEffect immer dann, wenn ein Effect asynchron arbeitet und sich seine Abhängigkeiten ändern können, bevor die erste asynchrone Operation abgeschlossen ist. Klassisches Beispiel ist ein Suchfeld: Der Nutzer tippt "re", dann "rea", dann "react". Für jeden Tastendruck startet useEffect einen neuen Fetch, weil sich der Suchbegriff als Abhängigkeit geändert hat. Die drei Requests laufen parallel im Netzwerk, und es gibt keine Garantie, dass sie in der Reihenfolge zurückkommen, in der sie gestartet wurden.

Serverlast, Netzwerklatenz oder ein einfach langsamerer Antwortpfad können dazu führen, dass die Antwort auf "re" erst nach der Antwort auf "react" eintrifft. Ohne Schutzmechanismus überschreibt dann der veraltete State-Update den bereits korrekten. Das UI zeigt kurzzeitig oder sogar dauerhaft falsche Daten an, obwohl der Code auf den ersten Blick fehlerfrei aussieht. Genau diese Klasse von Bugs ist tückisch, weil sie in der lokalen Entwicklung mit schneller Localhost-Antwortzeit kaum auftritt, aber unter echten Netzwerkbedingungen beim Nutzer regelmäßig zuschlägt.

2. Das klassische ignore-Flag-Muster

Vor AbortController war das gängige Muster ein lokales Boolean-Flag innerhalb der Cleanup-Function. Der Effect legt eine Variable ignore an, startet den Fetch, und prüft nach Rückkehr der Antwort, ob ignore inzwischen auf true gesetzt wurde. Die Cleanup-Function, die React bei jedem erneuten Ausführen des Effects sowie beim Unmount aufruft, setzt genau dieses Flag. So verhindert man zuverlässig, dass ein veralteter setState-Aufruf noch durchkommt.

Das Muster funktioniert, hat aber einen strukturellen Nachteil: Es verhindert nur das Schreiben des States, nicht aber den eigentlichen Netzwerk-Request. Der Browser lädt die Antwort trotzdem vollständig herunter, verbraucht Bandbreite und Serverressourcen, obwohl das Ergebnis am Ende weggeworfen wird. Bei kleinen JSON-Antworten ist das vernachlässigbar, bei großen Datenmengen, Datei-Uploads oder vielen parallelen Requests summiert sich das zu spürbarer Verschwendung. Zudem bleibt der Request in den Browser-DevTools als aktiv sichtbar, was das Debugging von Netzwerkproblemen unnötig erschwert.


function SearchResults({ query }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    let ignore = false;

    async function fetchResults() {
      const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
      const data = await response.json();
      if (!ignore) {
        setResults(data.items);
      }
    }

    fetchResults();

    return () => {
      ignore = true;
    };
  }, [query]);

  return (
    <ul>
      {results.map((item) => (
        <li key={item.id}>{item.title}</li>
      ))}
    </ul>
  );
}

3. AbortController als native Lösung

AbortController ist eine standardisierte Web-API, die genau für diesen Anwendungsfall gebaut wurde: Sie erlaubt es, eine laufende asynchrone Operation aktiv abzubrechen statt nur ihr Ergebnis zu ignorieren. Ein AbortController-Objekt besitzt eine signal-Eigenschaft, die man an fetch() übergibt. Ruft man anschließend controller.abort() auf, wirft der laufende Fetch einen AbortError und bricht die Netzwerkverbindung tatsächlich ab, statt nur auf die Antwort zu warten und sie zu verwerfen.

Der entscheidende Vorteil gegenüber dem ignore-Flag ist also die Symmetrie zwischen Absicht und Wirkung: Wenn eine Anfrage nicht mehr relevant ist, wird sie auch wirklich beendet. Das spart Bandbreite, entlastet den Server, und macht das Verhalten in den Browser-DevTools transparent nachvollziehbar, weil abgebrochene Requests dort explizit als cancelled markiert werden. Für Produktions-Apps mit vielen gleichzeitigen Nutzern und häufigen Tippgesten ist das kein Nice-to-have, sondern ein spürbarer Unterschied in der Serverlast.

4. AbortController in useEffect korrekt einsetzen

Die Implementierung folgt demselben Cleanup-Muster wie beim ignore-Flag, ersetzt aber die Boolean-Variable durch einen echten AbortController. Der Controller wird zu Beginn des Effects erzeugt, sein signal an fetch() übergeben, und in der Cleanup-Function ruft man controller.abort() auf. React führt diese Cleanup-Function automatisch aus, bevor der Effect erneut läuft oder die Komponente unmountet, sodass jeder veraltete Request zuverlässig terminiert wird, bevor ein neuer startet.

Wichtig ist die Fehlerbehandlung: Ein abgebrochener Fetch wirft einen DOMException vom Typ AbortError. Diesen Fehler sollte man im catch-Block explizit herausfiltern, denn er ist kein echter Fehlerfall, sondern das erwartete Verhalten eines absichtlich abgebrochenen Requests. Würde man ihn wie einen normalen Netzwerkfehler behandeln, zeigt das UI fälschlicherweise eine Fehlermeldung an, obwohl der Nutzer lediglich weitergetippt hat.


function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    async function fetchResults() {
      try {
        const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
          signal: controller.signal,
        });
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }
        const data = await response.json();
        setResults(data.items);
        setError(null);
      } catch (err) {
        if (err.name === "AbortError") {
          return; // erwartetes Verhalten, kein echter Fehler
        }
        setError(err.message);
      }
    }

    fetchResults();

    return () => {
      controller.abort();
    };
  }, [query]);

  if (error) return <p role="alert">Fehler: {error}</p>;
  return (
    <ul>
      {results.map((item) => (
        <li key={item.id}>{item.title}</li>
      ))}
    </ul>
  );
}

5. Einen wiederverwendbaren useAbortableFetch-Hook bauen

Sobald mehrere Komponenten dasselbe Muster brauchen, lohnt sich ein Custom Hook, der Fetch, AbortController und Fehlerbehandlung kapselt. Der Hook nimmt eine URL entgegen, verwaltet intern data, loading und error als State, und übernimmt die Abbruchlogik komplett innerhalb des internen useEffect. Nach außen bleibt die Nutzung denkbar einfach: Eine Komponente ruft useAbortableFetch(url) auf und erhält ein Objekt mit den drei Zuständen zurück, ohne sich um Abbruchdetails kümmern zu müssen.

Diese Kapselung zahlt sich vor allem in größeren Codebasen aus, in denen dasselbe Datenabruf-Muster in Dutzenden Komponenten auftaucht. Statt in jeder Komponente erneut AbortController-Boilerplate zu schreiben, importiert man den Hook einmal und bekommt konsistentes, race-condition-sicheres Verhalten überall geschenkt. Änderungen an der Fehlerbehandlung, etwa das Hinzufügen von Retry-Logik, müssen dann nur an einer zentralen Stelle gepflegt werden.


function useAbortableFetch(url) {
  const [state, setState] = useState({ data: null, loading: true, error: null });

  useEffect(() => {
    const controller = new AbortController();
    setState({ data: null, loading: true, error: null });

    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((data) => setState({ data, loading: false, error: null }))
      .catch((err) => {
        if (err.name === "AbortError") return;
        setState({ data: null, loading: false, error: err.message });
      });

    return () => controller.abort();
  }, [url]);

  return state;
}

// Verwendung:
function UserProfile({ userId }) {
  const { data, loading, error } = useAbortableFetch(`/api/users/${userId}`);

  if (loading) return <p>Lädt...</p>;
  if (error) return <p role="alert">Fehler: {error}</p>;
  return <h2>{data.name}</h2>;
}

6. Mehrere parallele Requests koordiniert abbrechen

Manche Komponenten lösen pro Effect-Durchlauf mehrere Requests gleichzeitig aus, etwa wenn ein Dashboard-Widget zeitgleich Nutzerdaten, Statistiken und Benachrichtigungen lädt. Ein einzelner AbortController kann problemlos an mehrere fetch()-Aufrufe übergeben werden, weil dieselbe signal-Instanz an beliebig viele Requests gebunden werden darf. Ruft man controller.abort() auf, brechen alle daran hängenden Requests gleichzeitig ab, ohne dass man für jeden einzelnen ein eigenes Cleanup schreiben muss.

Für Fälle, in denen die Requests unabhängig voneinander steuerbar sein müssen, etwa weil ein Teil der Daten kritisch ist und ein anderer Teil optional nachgeladen wird, bietet sich Promise.allSettled() in Kombination mit dem gemeinsamen Signal an. So bleibt ein fehlgeschlagener oder abgebrochener Teil-Request isoliert, während die übrigen Ergebnisse trotzdem verarbeitet werden können. Das verhindert, dass ein einzelner abgebrochener Request die gesamte Datenabruf-Pipeline blockiert.


function DashboardWidget({ userId }) {
  const [state, setState] = useState({ user: null, stats: null, notes: null });

  useEffect(() => {
    const controller = new AbortController();
    const opts = { signal: controller.signal };

    Promise.allSettled([
      fetch(`/api/users/${userId}`, opts).then((r) => r.json()),
      fetch(`/api/users/${userId}/stats`, opts).then((r) => r.json()),
      fetch(`/api/users/${userId}/notes`, opts).then((r) => r.json()),
    ]).then(([user, stats, notes]) => {
      setState({
        user: user.status === "fulfilled" ? user.value : null,
        stats: stats.status === "fulfilled" ? stats.value : null,
        notes: notes.status === "fulfilled" ? notes.value : null,
      });
    });

    return () => controller.abort();
  }, [userId]);

  return <pre>{JSON.stringify(state, null, 2)}</pre>;
}

7. Grenzen von AbortController und häufige Stolperfallen

AbortController löst nur Probleme bei asynchronen Operationen, die das signal auch tatsächlich respektieren. Die Fetch API unterstützt es nativ, aber ältere XMLHttpRequest-basierte Bibliotheken oder manche Drittanbieter-SDKs kennen dieses Konzept nicht und ignorieren ein übergebenes Signal stillschweigend. In solchen Fällen bleibt man auf ein ignore-Flag oder eine Ref-basierte Prüfung angewiesen, weil sich der zugrunde liegende Request nicht wirklich abbrechen lässt.

Eine weitere Falle ist das versehentliche Wiederverwenden eines bereits abgebrochenen Controllers. Ein AbortController kann nur einmal abgebrochen werden, und ein erneuter Fetch mit demselben, bereits abgebrochenen Signal schlägt sofort fehl. Deshalb muss der Controller innerhalb des Effects neu erzeugt werden und darf nicht außerhalb, etwa in einem Modul-Scope oder einem Ref, dauerhaft wiederverwendet werden. Wer diese Regel beachtet, vermeidet die häufigsten Bugs im Zusammenspiel mit AbortController.

8. AbortController-Verhalten testen

Beim Testen mit React Testing Library lässt sich das Abbruchverhalten gezielt provozieren, indem man eine Komponente rendert, sofort die Props ändert (etwa den Suchbegriff), und anschließend prüft, dass nur das Ergebnis der letzten Anfrage im DOM landet. Ein gemocktes fetch, das auf das übergebene signal reagiert und bei abort() tatsächlich eine Rejection auslöst, macht den Test realistisch statt ihn nur oberflächlich grün zu färgen.

Besonders wertvoll ist ein Test, der explizit prüft, dass bei schneller Prop-Änderung kein veralteter State mehr geschrieben wird. Dazu simuliert man zwei Fetch-Aufrufe mit unterschiedlicher künstlicher Verzögerung, wobei die erste, langsamere Anfrage erst nach der zweiten, schnelleren aufgelöst wird. Zeigt die Komponente danach trotzdem das Ergebnis der zweiten Anfrage an, ist der Race-Condition-Schutz nachweislich wirksam und nicht nur theoretisch korrekt.


test("zeigt nur das Ergebnis der letzten Anfrage", async () => {
  global.fetch = jest.fn((url, { signal }) =>
    new Promise((resolve, reject) => {
      const delay = url.includes("react") ? 10 : 100; // "react" antwortet zuerst
      const timer = setTimeout(
        () => resolve({ ok: true, json: async () => ({ items: [{ id: 1, title: url }] }) }),
        delay
      );
      signal.addEventListener("abort", () => {
        clearTimeout(timer);
        reject(new DOMException("Aborted", "AbortError"));
      });
    })
  );

  const { rerender } = render(<SearchResults query="re" />);
  rerender(<SearchResults query="react" />);

  await screen.findByText(/q=react/);
  expect(screen.queryByText(/q=re(?!act)/)).not.toBeInTheDocument();
});

9. Fazit und Vergleich der Ansätze

Sowohl das ignore-Flag als auch AbortController lösen das Grundproblem veralteter State-Updates zuverlässig, unterscheiden sich aber deutlich in ihren Nebeneffekten. Das Flag ist die einfachere, abhängigkeitsfreie Variante und eignet sich gut für kleine Prototypen oder Situationen, in denen der zugrunde liegende Datenabruf ohnehin nicht abbrechbar ist. AbortController ist die robustere Wahl für produktive Anwendungen, weil es Bandbreite spart, Serverlast reduziert und das Verhalten in den DevTools transparent macht.

In der Praxis empfiehlt sich AbortController als Standardmuster für alle fetch-basierten Effects, sobald die Anwendung über einen einfachen Prototyp hinausgeht. Wer zusätzlich TanStack Query oder SWR einsetzt, bekommt dieses Verhalten ohnehin automatisch mitgeliefert, da beide Bibliotheken laufende Requests bei veralteten Query-Keys intern abbrechen. Für alle Fälle, in denen man bewusst ohne zusätzliche Bibliothek arbeitet, bleibt der hier gezeigte useAbortableFetch-Hook eine solide, wartbare Grundlage.

Aspekt ignore-Flag AbortController Bibliothek (TanStack Query)
Verhindert veralteten State Ja Ja Ja
Bricht Netzwerk-Request wirklich ab Nein Ja Ja
Zusätzliche Abhängigkeit nötig Nein Nein Ja
Sichtbar in DevTools als abgebrochen Nein Ja Ja
Empfohlen für Prototypen Produktiv-Apps ohne Query-Lib Große Apps mit Caching-Bedarf

Mironsoft

React-Architektur, Performance und Magento-Frontend-Integration

React-Frontends, die schnell bleiben statt mit jedem Feature langsamer zu werden?

Wir prüfen bestehende React-Anwendungen auf unnötige Re-Renders, aufgeblähte Bundles und fragile State-Verwaltung und bauen daraus ein Frontend, das performant bleibt und sich sauber an Magento oder andere Backends anbindet.

Performance-Audit

Re-Renders, Bundle-Größe und Ladezeiten systematisch messen und beheben.

State-Architektur

Context, Zustand und Server State sauber trennen statt alles in einen Topf zu werfen.

Magento-Integration

GraphQL- oder REST-Anbindung an Magento robust und typsicher aufbauen.

10. Zusammenfassung

Race Conditions in useEffect: Das Wichtigste auf einen Blick

Problem

Veraltete useEffect-Antworten überschreiben neuere Antworten im UI.

Klassische Lösung

ignore-Flag in der Cleanup-Function verhindert nur das State-Update.

Bessere Lösung

AbortController bricht den Request wirklich ab und spart Ressourcen.

Praxis-Tipp

AbortError im catch-Block explizit von echten Fehlern unterscheiden.

11. FAQ: Race Conditions in useEffect: Das Wichtigste auf einen Blick

1Was genau ist eine Race Condition in useEffect?
Eine Race Condition tritt auf, wenn mehrere asynchrone Operationen aus aufeinanderfolgenden Effect-Durchläufen in unvorhersehbarer Reihenfolge zurückkommen und die spätere Antwort einer älteren Anfrage einen bereits aktuelleren State überschreibt.
2Reicht das ignore-Flag nicht aus?
Das ignore-Flag verhindert zwar das fehlerhafte State-Update, lässt den zugrunde liegenden Netzwerk-Request aber unverändert vollständig durchlaufen, was unnötig Bandbreite und Serverressourcen verbraucht.
3Wie übergebe ich AbortController an fetch?
Man erzeugt einen AbortController, übergibt dessen signal-Eigenschaft im Optionsobjekt von fetch als signal-Feld und ruft bei Bedarf controller.abort() auf, um die Anfrage zu beenden.
4Was passiert, wenn ein Request abgebrochen wird?
Der laufende Fetch wirft einen DOMException vom Typ AbortError, den man im catch-Block gezielt abfangen und von echten Netzwerkfehlern unterscheiden sollte.
5Kann ich einen AbortController mehrfach verwenden?
Nein, ein einmal abgebrochener AbortController kann nicht wiederverwendet werden. Für jeden neuen Effect-Durchlauf muss ein neuer Controller erzeugt werden.
6Funktioniert AbortController mit älteren XMLHttpRequest-Bibliotheken?
Nicht automatisch. Nur APIs, die das AbortSignal explizit unterstützen, respektieren einen Abbruch. Ältere Bibliotheken benötigen weiterhin ein ignore-Flag oder eigene Abbruchmechanismen.
7Brauche ich AbortController auch bei TanStack Query?
Nein, TanStack Query und ähnliche Bibliotheken brechen veraltete Requests bei geänderten Query-Keys intern automatisch ab, sodass man das Muster nicht selbst implementieren muss.
8Wie teste ich Race-Condition-Schutz sinnvoll?
Man simuliert zwei Fetch-Aufrufe mit unterschiedlicher Verzögerung, ändert die Props der Komponente sofort nach dem ersten Render und prüft, dass am Ende nur das Ergebnis der zuletzt gestarteten Anfrage sichtbar ist.
9Kann ein AbortController mehrere Requests gleichzeitig abbrechen?
Ja, dieselbe signal-Instanz eines Controllers lässt sich an beliebig viele fetch-Aufrufe binden, sodass ein einziger abort()-Aufruf alle daran hängenden Requests gleichzeitig beendet.
10Lohnt sich ein eigener Hook für AbortController-Logik?
Ja, sobald mehrere Komponenten denselben Datenabruf mit Abbruchlogik benötigen, kapselt ein wiederverwendbarer Hook wie useAbortableFetch die Boilerplate zentral und sorgt für konsistentes Verhalten.