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.
Inhaltsverzeichnis
- 1. Warum Race Conditions in useEffect so leicht entstehen
- 2. Das klassische ignore-Flag-Muster
- 3. AbortController als native Lösung
- 4. AbortController in useEffect korrekt einsetzen
- 5. Einen wiederverwendbaren useAbortableFetch-Hook bauen
- 6. Mehrere parallele Requests koordiniert abbrechen
- 7. Grenzen von AbortController und häufige Stolperfallen
- 8. AbortController-Verhalten testen
- 9. Fazit und Vergleich der Ansätze
- 10. Zusammenfassung
- 11. FAQ
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.