Daten von einer API laden ist die häufigste asynchrone Aufgabe in React-Apps. Ein handgeschriebener useFetch-Hook kapselt Loading-State, Error-Handling und Race-Condition-Schutz in einer wiederverwendbaren Einheit — die Komponente kümmert sich nur ums Rendern. Dieser Artikel zeigt eine produktionsreife Variante mit AbortController-Cleanup (verhindert Memory-Leaks und Stale-Updates beim Unmount oder Query-Wechsel), einer Reload-Funktion für manuelles Neuladen und einer kompletten Verbraucher-Komponente. Am Ende ein Vergleich mit TanStack Query — die ausgereifte Library, die bei größeren Apps die handgeschriebene Lösung ablöst.
Was der Hook leisten soll
Aus Konsumenten-Sicht:
const { data, isLoading, error, reload } = useFetch('/api/users');
if (isLoading) return <Spinner />;
if (error) return <p>Fehler: {error.message}</p>;
return <UserList users={data} onRefresh={reload} />;Vier State-Slots als Rückgabe:
data: die geladenen Daten odernull, solange noch nichts da ist.isLoading:truewährend eines aktiven Requests, sonstfalse.error: ein Error-Objekt, wenn der Request fehlgeschlagen ist; sonstnull.reload: eine Funktion, mit der die Komponente einen erneuten Fetch auslösen kann (z.B. nach einer Mutation).
Innen muss der Hook fünf Edge-Cases sauber handhaben:
- URL-Wechsel — wenn sich die URL ändert, soll der alte Request abgebrochen und ein neuer gestartet werden.
- Unmount während laufendem Request — Component verschwindet, bevor
fetchantwortet → setState im Then-Callback wäre Memory-Leak und löst React-Warning aus. - HTTP-Fehler-Status (z.B. 404, 500) —
fetchbetrachtet das nicht als Error, der Promise resolvet trotzdem. Wir müssenresponse.okselbst prüfen. - Network-Fehler (offline, DNS) — der Promise rejectet mit einem TypeError. Sauber abfangen.
- Race-Conditions — zwei schnelle Requests hintereinander, die in falscher Reihenfolge antworten. Der letzte gestartete soll gewinnen, nicht der letzte angekommene.
Der Hook mit AbortController
import { useState, useEffect, useCallback } from 'react';
export function useFetch(url, options) {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [reloadCount, setReloadCount] = useState(0);
// Reload-Funktion: triggert den Effect erneut via Counter
const reload = useCallback(() => {
setReloadCount(c => c + 1);
}, []);
useEffect(() => {
// 1. AbortController für sauberen Cleanup
const controller = new AbortController();
const { signal } = controller;
// 2. Loading-State zurücksetzen
setIsLoading(true);
setError(null);
// 3. Async-IIFE — useEffect-Callback darf nicht direkt async sein
(async () => {
try {
const response = await fetch(url, { ...options, signal });
// 4. HTTP-Fehler-Status manuell prüfen
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const json = await response.json();
// 5. Nur State setzen, wenn der Request nicht abgebrochen wurde
if (!signal.aborted) {
setData(json);
setIsLoading(false);
}
} catch (err) {
// 6. AbortError ist KEIN echter Fehler — bewusst abgebrochen
if (err.name === 'AbortError') return;
if (!signal.aborted) {
setError(err);
setIsLoading(false);
}
}
})();
// 7. Cleanup: alten Request abbrechen, wenn URL wechselt oder Unmount
return () => controller.abort();
}, [url, reloadCount]);
return { data, isLoading, error, reload };
}Sieben Designentscheidungen, durchnummeriert:
-
AbortControllerstattisMounted-Boolean. Klassisches Pattern warlet isMounted = true; ... if (isMounted) setState(...). Das verhindert zwar Memory-Leaks, aber der Network-Request läuft trotzdem weiter — verschwendet Bandbreite und Server-Ressourcen.AbortControllerbricht den Request wirklich ab. -
isLoadingunderrorresetten bei jedem Run. Wenn die URL wechselt, müssen alte Werte raus — sonst zeigt die Komponente kurz alte Daten neben „Lade…", oder einen alten Error neben neuen Daten. Sauberer State-Reset am Anfang jedes Effects. -
Async-IIFE statt
async () => {}als Effect-Callback. DeruseEffect-Callback darf NICHT direkt async sein — er erwartet ein Cleanup-Funktion-Return, nicht ein Promise. Eine sofort-aufgerufene async-Funktion löst das. -
response.okmanuell prüfen. Hier liegt eine berüchtigtefetch-Falle: HTTP-Fehler-Statuscodes (404, 500, 503) lassenfetchresolven, nicht rejecten.response.okisttruenur für 200-299. Alles andere müssen wir selbst zu Fehlern machen. -
signal.aborted-Check vorsetState. Doppelter Schutz: derAbortControllerwirft beim Abbruch automatisch einenAbortError(siehe Punkt 6), aber zwischen Erfolg und State-Update kann auch der Race-Case auftreten. Pre-State-Check macht beide Pfade sicher. -
AbortErrorschweigend ignorieren. Wenn wir den Request bewusst abgebrochen haben (z.B. weil die URL gewechselt hat), ist das kein Fehler — wir wollen die Komponente nicht mit einer fehlerhaften Anzeige verwirren.err.name === 'AbortError'erkennt diesen Fall. -
Cleanup mit
controller.abort(). Bei jedem Re-Run des Effects (URL-Wechsel, Reload) und beim Unmount wird der laufende Request abgebrochen. Das ist der ganze Punkt vonAbortController.
Konsumieren in einer Komponente
import { useFetch } from './useFetch';
export default function UserList() {
const { data, isLoading, error, reload } = useFetch(
'https://dummyjson.com/users?limit=5'
);
if (isLoading) return <p>Lade Benutzer …</p>;
if (error) return <p role="alert">Fehler: {error.message}</p>;
return (
<>
<button onClick={reload}>Neu laden</button>
<ul>
{data.users.map(user => (
<li key={user.id}>
#{user.id} – {user.firstName} {user.lastName}
</li>
))}
</ul>
</>
);
}Drei Render-Pfade:
- Loading: erster Render und bei jeder URL-Änderung. Spinner oder einfache Text-Meldung.
- Error: HTTP-Fehler oder Network-Problem.
role="alert"ist für Screen-Reader essenziell. - Erfolg: Daten sind da, Liste wird gerendert.
Reload-Pattern: der Button ruft reload(), was den reloadCount-State im Hook hochzählt und damit den Effect neu triggert. Der alte (gerade beendete) Request wird per AbortController abgebrochen, ein neuer startet.

Race-Conditions verstehen
Ein typisches Race-Szenario: der User tippt schnell in ein Such-Feld, das pro Tastendruck einen Fetch macht.
// User tippt "react" — fünf Requests in schneller Folge
const [query, setQuery] = useState('');
const { data } = useFetch(`/api/search?q=${query}`);
// Request 1: /api/search?q=r (langsam)
// Request 2: /api/search?q=re (mittel)
// Request 3: /api/search?q=rea (schnell)
// Request 4: /api/search?q=reac (langsam)
// Request 5: /api/search?q=react (mittel)Ohne Cleanup: jeder Request liefert irgendwann ein Resultat. Wenn Request 3 (schnell) zuletzt fertig wird, sieht der User falsche Daten — Resultate für „rea" statt für „react". Klassischer Race-Condition-Bug.
Mit AbortController im Cleanup: sobald sich query ändert, läuft der Effect-Cleanup → controller.abort() → der alte Request stirbt. Nur die Antwort vom letzten Request wird angezeigt. Bei sehr schnellen Tastatur-Eingaben werden vier von fünf Requests gar nicht zu Ende geführt — auch ein Performance-Vorteil.
Profi-Tipp für Such-Felder: zusätzlich zum AbortController macht ein Debounce Sinn — der Fetch wird gar nicht erst gestartet, bis der User aufgehört hat zu tippen. Details im useDebounce-Artikel.
Wo dieser Hook an Grenzen kommt
useFetch deckt einfache Read-Cases ab. Bei produktiver Anwendung kommen schnell Anforderungen, die der handgeschriebene Hook nicht sauber löst:
- Caching zwischen Komponenten. Drei Komponenten brauchen
/api/user/42— der Hook macht drei separate Requests, statt einmal zu laden und zu cachen. - Stale-While-Revalidate: zeige alte Daten sofort, lade im Hintergrund neue. Verbessert wahrnehmbare Performance.
- Mutations mit Optimistic-Updates und automatischem Cache-Invalidating.
- Pagination & Infinite-Scroll mit gemerkten Seiten und Prefetch.
- Polling/Refetch on focus: regelmäßiges Reload oder bei Tab-Fokus.
- Retry mit Exponential Backoff bei transienten Network-Fehlern.
- Suspense-Integration: in React 18+ data-fetching mit
<Suspense>und<ErrorBoundary>.
All das löst TanStack Query (früher React Query) — die De-facto-Standard-Library für Server-State in React.
import { useQuery } from '@tanstack/react-query';
function UserList() {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(r => r.json()),
staleTime: 60_000, // 60s Cache
refetchOnWindowFocus: true, // Bei Tab-Fokus neu laden
});
// Identisches Konsumenten-Interface wie unser useFetch
// — aber Cache, Stale-While-Revalidate, Retries usw. fertig
}Faustregel: useFetch als Eigenbau ist perfekt für Lernen und kleine Apps. Ab dem Moment, wo zwei Komponenten dieselben Daten brauchen oder Mutations dazukommen — TanStack Query nutzen.
Besonderheiten
fetch rejected NICHT bei HTTP-Fehler-Status — `response.ok` manuell prüfen.
response.ok ist nur für 200-299 true. 404, 500, 503 resolven trotzdem. Klassische Falle, die in fast jedem ungeprüften Code zu „Daten geladen, aber leer"-Bugs führt.
useEffect-Callback darf NICHT direkt `async` sein.
Async-Funktionen geben ein Promise zurück. React erwartet aber eine Cleanup-Funktion oder undefined. Lösung: async-IIFE im Body, oder eine async-Helper-Funktion drinnen aufrufen.
AbortController ist die richtige Antwort auf Race-Conditions UND Memory-Leaks.
Der ältere let isMounted = true-Pattern verhindert setState nach Unmount, aber lässt den Request weiterlaufen. AbortController bricht den Request wirklich ab — Bandbreite gespart, Server-Last reduziert, kein Stale-Update möglich.
AbortError ist KEIN echter Fehler — explizit ignorieren.
Wenn wir den Request bewusst abgebrochen haben, soll die UI keinen Fehler zeigen. err.name === 'AbortError' erkennt den Fall. err instanceof DOMException wäre Alternative, aber Name-Check ist Browser-übergreifend zuverlässiger.
Reload via Counter-State triggert den Effect ohne URL-Änderung.
setReloadCount(c => c + 1) + reloadCount als Dependency → Effect läuft neu. Cleaner als useReducer({type: 'reload'}) oder Ref-Tricks.
State immer am Effect-Anfang resetten.
setIsLoading(true) und setError(null) als ersten Schritt im Effect verhindern, dass die Komponente bei URL-Wechsel kurz alte Daten neben „Lade…" zeigt. Wichtiger UX-Punkt.
Bei zwei oder mehr Komponenten mit denselben Daten: TanStack Query.
Drei Komponenten, die alle /api/user/42 brauchen, machen mit handgeschriebenem useFetch drei separate Requests. TanStack Query lädt einmal, cached und teilt zwischen Konsumenten. Diese Skalierung ist die Hauptgrund-Migration.
Für Auto-Search mit User-Input: Debounce + AbortController kombinieren.
Debounce verhindert Requests während des Tippens (Performance). AbortController bricht alte Requests ab, wenn doch zwei in Flug sind (Korrektheit). Beides zusammen ist das produktionsreife Search-Pattern.
Weiterführende Ressourcen
Externe Quellen
- AbortController – MDN
- Fetch API – MDN
- TanStack Query Documentation
- SWR Documentation
- Synchronizing with Effects – react.dev