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:

TypeScript ZielAPI.jsx
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 oder null, solange noch nichts da ist.
  • isLoading: true während eines aktiven Requests, sonst false.
  • error: ein Error-Objekt, wenn der Request fehlgeschlagen ist; sonst null.
  • 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:

  1. URL-Wechsel — wenn sich die URL ändert, soll der alte Request abgebrochen und ein neuer gestartet werden.
  2. Unmount während laufendem Request — Component verschwindet, bevor fetch antwortet → setState im Then-Callback wäre Memory-Leak und löst React-Warning aus.
  3. HTTP-Fehler-Status (z.B. 404, 500) — fetch betrachtet das nicht als Error, der Promise resolvet trotzdem. Wir müssen response.ok selbst prüfen.
  4. Network-Fehler (offline, DNS) — der Promise rejectet mit einem TypeError. Sauber abfangen.
  5. Race-Conditions — zwei schnelle Requests hintereinander, die in falscher Reihenfolge antworten. Der letzte gestartete soll gewinnen, nicht der letzte angekommene.

Der Hook mit AbortController

TypeScript useFetch.js
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:

  1. AbortController statt isMounted-Boolean. Klassisches Pattern war let isMounted = true; ... if (isMounted) setState(...). Das verhindert zwar Memory-Leaks, aber der Network-Request läuft trotzdem weiter — verschwendet Bandbreite und Server-Ressourcen. AbortController bricht den Request wirklich ab.

  2. isLoading und error resetten 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.

  3. Async-IIFE statt async () => {} als Effect-Callback. Der useEffect-Callback darf NICHT direkt async sein — er erwartet ein Cleanup-Funktion-Return, nicht ein Promise. Eine sofort-aufgerufene async-Funktion löst das.

  4. response.ok manuell prüfen. Hier liegt eine berüchtigte fetch-Falle: HTTP-Fehler-Statuscodes (404, 500, 503) lassen fetch resolven, nicht rejecten. response.ok ist true nur für 200-299. Alles andere müssen wir selbst zu Fehlern machen.

  5. signal.aborted-Check vor setState. Doppelter Schutz: der AbortController wirft beim Abbruch automatisch einen AbortError (siehe Punkt 6), aber zwischen Erfolg und State-Update kann auch der Race-Case auftreten. Pre-State-Check macht beide Pfade sicher.

  6. AbortError schweigend 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.

  7. 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 von AbortController.

Konsumieren in einer Komponente

TypeScript UserList.jsx
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.

React Custom Hook - Beispiel mit API

Race-Conditions verstehen

Ein typisches Race-Szenario: der User tippt schnell in ein Such-Feld, das pro Tastendruck einen Fetch macht.

TypeScript RaceProblem.jsx
// 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.

TypeScript TanStackQuery.jsx
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(&#123;type: 'reload'&#125;) 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

/ Weiter

Zurück zu Custom Hooks

Zur Übersicht