Daten von einer API in einer Komponente
Kurz gesagt: Daten von einer API werden in einer React-Komponente aus useEffect geladen, mit dem Zustand „lädt“, „Fehler“ oder „fertig“, und ein AbortController im Aufräumen stoppt die veraltete Anfrage.
Problem
Äpfel unter „Getränke“
Der Katalog holt die Produkte der Kategorie von der API. Hier antwortet der Server für „Obst und Gemüse“ in 1,5 Sekunden und für die anderen Kategorien in 0,3.
Konsole
Klick auf „Obst und Gemüse“ und gleich danach auf „Getränke“. Die Getränke erscheinen. Eine Sekunde später nehmen Äpfel, Bananen und Tomaten ihren Platz ein, während die Überschrift und der gedrückte Button weiter „Getränke“ sagen. Der Kunde sieht die falschen Produkte und weiß nicht, warum.
Die Idee
Eine Anfrage ist ein Effekt
Die Anfrage verlässt die Komponente in Richtung Server. Damit ist sie eine Nebenwirkung, und ihr Platz ist im Effekt, mit der Kategorie als Abhängigkeit: Jede neue Kategorie schickt eine neue Anfrage.
Die Zustände sind die aus der Lektion über fetch in der Seite: lädt, Fehler, fertig. Hier sind sie ein einziger Zustand, status, und die Komponente zeichnet den passenden Zweig, wie in der Lektion über bedingtes Zeichnen.
useEffect(() => { async function load() { setStatus('loading'); try { const response = await fetch(`/api/products?category=${categoryId}`); if (!response.ok) throw new Error(`HTTP ${response.status}`); setProducts(await response.json()); setStatus('ready'); } catch (e) { setStatus('error'); } } load();}, [categoryId]);Die Funktion des Effekts darf nicht async sein: Sie muss das Aufräumen zurückgeben, und eine async-Funktion gibt ein Promise zurück. Deshalb ist load eine eigene async-Funktion im Effekt, die gleich aufgerufen wird.
status wird bei jedem Laden zu 'loading', nicht nur beim ersten Mal. Sonst bleibt bei einem Wechsel die alte Liste unter der neuen Überschrift stehen, bis die Antwort kommt.
Probier es aus
Lade die Kategorie
Catalog hat die Zustände products und status, aber nichts füllt sie. Lade die Produkte von categoryId von /api/products?category=… und zeige bei jedem Laden „Wird geladen…“. Die Prüfung schaut auf die Milchprodukte (5 Produkte), klickt dann auf „Getränke“ und wartet auf „Wird geladen…“ und 4 Produkte.
Konsole
Lösung
App.jsximport { useState } from 'react';
import Catalog from './Catalog.jsx';
const CATEGORIES = [
{ id: 4, name: 'Milchprodukte' },
{ id: 5, name: 'Obst und Gemüse' },
{ id: 6, name: 'Getränke' },
];
export default function App() {
const [categoryId, setCategoryId] = useState(4);
const category = CATEGORIES.find((c) => c.id === categoryId);
return (
<main>
<nav aria-label="Kategorien">
{CATEGORIES.map((c) => (
<button key={c.id} type="button" aria-pressed={c.id === categoryId} onClick={() => setCategoryId(c.id)}>
{c.name}
</button>
))}
</nav>
<h1>{category.name}</h1>
<Catalog categoryId={categoryId} />
</main>
);
}
Catalog.jsximport { useEffect, useState } from 'react';
import { money } from './money.js';
export default function Catalog({ categoryId }) {
const [products, setProducts] = useState([]);
const [status, setStatus] = useState('loading');
useEffect(() => {
async function load() {
setStatus('loading');
try {
const response = await fetch(`/api/products?category=${categoryId}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
setProducts(await response.json());
setStatus('ready');
} catch (e) {
setStatus('error');
}
}
load();
}, [categoryId]);
if (status === 'loading') return <p role="status">Wird geladen…</p>;
if (status === 'error') return <p role="alert">Die Produkte wurden nicht geladen.</p>;
return (
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}, {money(p.price)}</li>
))}
</ul>
);
}
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
nav { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
button { font: inherit; }
button[aria-pressed="true"] { background: #c27c2a; color: #fff; border-color: #c27c2a; }
ul { padding-left: 20px; }
[role="alert"] { color: #b3261e; }
Der Effekt mit der Abhängigkeit [categoryId] ruft load() auf, das 'loading' setzt, auf die Antwort wartet und die Produkte und 'ready' setzt. Die Prüfung erkennt []: Die Milchprodukte laden, und ein Wechsel der Kategorie bewirkt nichts.
Die Prüfung erkennt auch 'loading', das nur beim Start der Komponente gesetzt wird: Beim Wechsel der Kategorie erscheint „Wird geladen…“ nicht.
Die Idee
Der Wettlauf
Jeder Klick ändert die Kategorie und führt den Effekt noch einmal aus. Jeder Lauf schickt seine eigene Anfrage und wartet auf seine eigene Antwort. Keiner weiß von den anderen.
Antworten kommen nicht in der Reihenfolge der Anfragen. Der Server sucht das Obst vielleicht länger, das Netz bremst eine von beiden. Wenn der Kunde schnell wechselt, sind also zwei Anfragen gleichzeitig unterwegs. Die, die zuletzt antwortet, gewinnt, nicht die, die zuletzt losging.
Das heißt Wettlaufbedingung (Race Condition). Sie zeigt sich nicht, solange der Server schnell und gleichmäßig ist. Sie zeigt sich in einem langsamen Netz, auf einem Handy, bei einem ausgelasteten Server.
Sieh es dir an
Zwei Anfragen unterwegs
Die Balken wachsen, solange eine Anfrage unterwegs ist. Zuerst ohne Aufräumen, dann mit.
useEffect(() => { const controller = new AbortController(); async function load() { setStatus('loading'); try { const url = `/api/products?category=${categoryId}`; const response = await fetch(url, { signal: controller.signal }); if (!response.ok) throw new Error(`HTTP ${response.status}`); setProducts(await response.json()); setStatus('ready'); } catch (e) { if (e.name === 'AbortError') return; setStatus('error'); } } load(); return () => controller.abort();}, [categoryId]);Schritt für Schritt, in Worten
- Der Kunde klickt auf „Obst und Gemüse“. Die Kategorie ändert sich, der Effekt läuft und schickt eine Anfrage. Diese ist langsam.
- Bevor sie antwortet, klickt der Kunde auf „Getränke“. Der Effekt läuft wieder und schickt eine zweite Anfrage. Jetzt sind zwei Anfragen gleichzeitig unterwegs.
- Die schnelle Anfrage antwortet zuerst: Die Liste zeigt die Getränke.
- Angenommen, das Aufräumen fehlt. Die langsame Anfrage antwortet zuletzt, ihr Lauf setzt ebenfalls die Produkte, und unter „Getränke“ steht Obst.
- Jetzt mit dem Aufräumen. Jeder Lauf des Effekts erzeugt seinen eigenen AbortController und gibt dessen signal an fetch weiter.
- Die neue Kategorie ruft das Aufräumen des alten Laufs auf: abort() stoppt die langsame Anfrage. Ihr fetch wirft AbortError, und catch überspringt ihn: Das ist kein Fehler.
- Nur die Anfrage für die Getränke antwortet. Liste und Überschrift passen zusammen.
- Die Regel: Eine Anfrage in einem Effekt hat im Aufräumen einen Abbruch. Der letzte Lauf gewinnt, weil alle davor gestoppt wurden.
Ohne Aufräumen zeigt der Bildschirm die letzte Antwort. Mit Aufräumen wird die alte Anfrage gestoppt, bevor sie antwortet, und die letzte Antwort gehört immer zur letzten Kategorie.
Die Idee
Abbrechen im Aufräumen
Der Effekt hat schon einen Platz für „stopp das Alte“: das Aufräumen. Der Browser hat ein Werkzeug, um eine Anfrage zu stoppen: AbortController.
- Jeder Lauf des Effekts erzeugt seinen eigenen
controller = new AbortController(). - Er gibt sein
controller.signalanfetchweiter:fetch(url, { signal: controller.signal }). - Das Aufräumen ruft
controller.abort()auf. Die Anfrage stoppt, undawait fetch(…)wirft einen Fehler namensAbortError. catchüberspringt diesen Fehler:if (e.name === 'AbortError') return;. Die Anfrage ist nicht fehlgeschlagen; der Code hat sie gestoppt.
useEffect(() => { const controller = new AbortController(); async function load() { setStatus('loading'); try { const response = await fetch(url, { signal: controller.signal }); … } catch (e) { if (e.name === 'AbortError') return; setStatus('error'); } } load(); return () => controller.abort();}, [categoryId]);Das Aufräumen läuft auch, wenn die Komponente verschwindet. Der Kunde hat den Katalog verlassen: Die Anfrage stoppt, und niemand setzt Produkte in einer Komponente, die es nicht mehr gibt.
Probier es aus
Stoppe die alte Anfrage
Sorge dafür, dass die alte Anfrage stoppt, wenn der Kunde schnell wechselt. Die Prüfung klickt auf „Obst und Gemüse“ und gleich danach auf „Getränke“, wartet auf „Wird geladen…“ und die Getränke, wartet die langsame Antwort ab und stellt sicher, dass keine Äpfel dastehen und keine Fehlermeldung erschienen ist.
Konsole
Lösung
App.jsximport { useState } from 'react';
import Catalog from './Catalog.jsx';
const CATEGORIES = [
{ id: 4, name: 'Milchprodukte' },
{ id: 5, name: 'Obst und Gemüse' },
{ id: 6, name: 'Getränke' },
];
export default function App() {
const [categoryId, setCategoryId] = useState(4);
const category = CATEGORIES.find((c) => c.id === categoryId);
return (
<main>
<nav aria-label="Kategorien">
{CATEGORIES.map((c) => (
<button key={c.id} type="button" aria-pressed={c.id === categoryId} onClick={() => setCategoryId(c.id)}>
{c.name}
</button>
))}
</nav>
<h1>{category.name}</h1>
<Catalog categoryId={categoryId} />
</main>
);
}
Catalog.jsximport { useEffect, useState } from 'react';
import { money } from './money.js';
export default function Catalog({ categoryId }) {
const [products, setProducts] = useState([]);
const [status, setStatus] = useState('loading');
useEffect(() => {
const controller = new AbortController();
async function load() {
setStatus('loading');
try {
const response = await fetch(`/api/products?category=${categoryId}`, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
setProducts(await response.json());
setStatus('ready');
} catch (e) {
if (e.name === 'AbortError') return;
setStatus('error');
}
}
load();
return () => controller.abort();
}, [categoryId]);
if (status === 'loading') return <p role="status">Wird geladen…</p>;
if (status === 'error') return <p role="alert">Die Produkte wurden nicht geladen.</p>;
return (
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}, {money(p.price)}</li>
))}
</ul>
);
}
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
nav { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
button { font: inherit; }
button[aria-pressed="true"] { background: #c27c2a; color: #fff; border-color: #c27c2a; }
ul { padding-left: 20px; }
[role="alert"] { color: #b3261e; }
Der Effekt erzeugt einen AbortController, gibt das signal an fetch weiter und gibt () => controller.abort() zurück. catch überspringt AbortError.
Die Prüfung erkennt einen Controller, dessen signal nicht an fetch weitergegeben wird: abort() stoppt nichts, und die Äpfel kommen trotzdem. Sie erkennt auch ein catch, das AbortError nicht überspringt: Gleich nach „Getränke“ setzt der Abbruch 'error', und statt „Wird geladen…“ zeigt die Seite kurz „Die Produkte wurden nicht geladen.“.
Probier es aus
Finde den Fehler
Der Kunde wechselt schnell und sieht kurz „Die Produkte wurden nicht geladen.“, dann kommen die Produkte. Der Server hat keinen Fehler zurückgegeben. Welche Zeile ist schuld?
Wähle die Zeile mit dem Fehler.
Zeile 11: Jeder Fehler wird zu 'error', auch der Abbruch aus dem Aufräumen. Davor muss if (e.name === 'AbortError') return; stehen: Eine gestoppte Anfrage ist nicht fehlgeschlagen.
Probier es aus
Fülle die Lücke
Was stoppt die alte Anfrage?
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal }).then(show);
return () => controller.();
}, [categoryId]);abort: controller.abort() stoppt die Anfrage, deren signal an fetch übergeben wurde, und löst einen AbortError aus.
Die Idee
Fehler und „Noch einmal versuchen“
Manchmal antwortet der Server mit 500. Die Prüfung von response.ok wirft, catch setzt 'error', und die Komponente zeigt die Meldung mit role="alert". Der Kunde soll es noch einmal versuchen können, ohne die Seite neu zu laden.
Noch einmal versuchen heißt dasselbe Laden. Das macht der Effekt schon, wenn sich eine Abhängigkeit ändert. Also ändert „Noch einmal versuchen“ eine Abhängigkeit: eine Zahl attempt, die nur die Versuche zählt.
const [attempt, setAttempt] = useState(0);
useEffect(() => { // … dasselbe Laden}, [categoryId, attempt]);
<button type="button" onClick={() => setAttempt(attempt + 1)}>Noch einmal versuchen</button>Der neue Lauf setzt 'loading', deshalb verschwinden die Meldung und der Button, solange die Anfrage unterwegs ist.
Genauer, für Neugierige
Daten-Bibliotheken. In einem echten Projekt wird das Laden selten in jeder Komponente von Hand geschrieben. Es gibt Bibliotheken, die das und mehr erledigen: Sie halten die Antworten in einem Cache, schicken nicht zwei gleiche Anfragen, versuchen es von selbst noch einmal und aktualisieren die Daten, wenn der Kunde zum Tab zurückkommt. Für React sind das meist TanStack Query und SWR. Sie sind nicht in der Sandbox, und der Kurs hat keine Lektionen dazu. Im Inneren tun sie dasselbe wie hier: eine Anfrage in einem Effekt, Zustände, ein Abbruch.
Ein Flag statt eines Abbruchs. Statt eines AbortController kann das Aufräumen ein Flag setzen: let ignore = false; im Effekt, ignore = true im Aufräumen und if (!ignore) vor dem Setzen der Produkte. Die alte Anfrage erreicht den Server trotzdem, aber ihre Antwort wird verworfen. Ein Abbruch stoppt die Anfrage selbst. Beide Wege lösen den Wettlauf.
<StrictMode> läuft der Effekt, wird aufgeräumt und läuft noch einmal. Deshalb zeigt das Netzwerk zwei Anfragen, die erste abgebrochen. Das ist der Test, dass das Aufräumen funktioniert.Übung
Noch einmal versuchen
Hier bekommt die erste Anfrage für die Milchprodukte eine 500. Der Katalog zeigt „Die Produkte wurden nicht geladen.“, aber es gibt keine Möglichkeit, es noch einmal zu versuchen. Füge einen Button „Noch einmal versuchen“ hinzu, der das Laden erneut ausführt. Der Abbruch beim schnellen Wechseln muss bleiben. Die Prüfung wartet auf den Fehler, klickt auf „Noch einmal versuchen“, wartet auf „Wird geladen…“ und 5 Produkte und wechselt dann schnell zu Obst und Getränken.
Konsole
Lösung
App.jsximport { useState } from 'react';
import Catalog from './Catalog.jsx';
const CATEGORIES = [
{ id: 4, name: 'Milchprodukte' },
{ id: 5, name: 'Obst und Gemüse' },
{ id: 6, name: 'Getränke' },
];
export default function App() {
const [categoryId, setCategoryId] = useState(4);
const category = CATEGORIES.find((c) => c.id === categoryId);
return (
<main>
<nav aria-label="Kategorien">
{CATEGORIES.map((c) => (
<button key={c.id} type="button" aria-pressed={c.id === categoryId} onClick={() => setCategoryId(c.id)}>
{c.name}
</button>
))}
</nav>
<h1>{category.name}</h1>
<Catalog categoryId={categoryId} />
</main>
);
}
Catalog.jsximport { useEffect, useState } from 'react';
import { money } from './money.js';
export default function Catalog({ categoryId }) {
const [products, setProducts] = useState([]);
const [status, setStatus] = useState('loading');
const [attempt, setAttempt] = useState(0);
useEffect(() => {
const controller = new AbortController();
async function load() {
setStatus('loading');
try {
const response = await fetch(`/api/products?category=${categoryId}`, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
setProducts(await response.json());
setStatus('ready');
} catch (e) {
if (e.name === 'AbortError') return;
setStatus('error');
}
}
load();
return () => controller.abort();
}, [categoryId, attempt]);
if (status === 'loading') return <p role="status">Wird geladen…</p>;
if (status === 'error')
return (
<>
<p role="alert">Die Produkte wurden nicht geladen.</p>
<button type="button" onClick={() => setAttempt(attempt + 1)}>Noch einmal versuchen</button>
</>
);
return (
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}, {money(p.price)}</li>
))}
</ul>
);
}
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
nav { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
button { font: inherit; }
button[aria-pressed="true"] { background: #c27c2a; color: #fff; border-color: #c27c2a; }
ul { padding-left: 20px; }
[role="alert"] { color: #b3261e; }
const [attempt, setAttempt] = useState(0), die Abhängigkeiten werden [categoryId, attempt], und bei 'error' zeigt die Komponente die Meldung und einen Button mit onClick={() => setAttempt(attempt + 1)}. Die Prüfung erkennt einen Button, der attempt ändert, während attempt in den Abhängigkeiten fehlt: Es startet nichts.
Die Prüfung erkennt auch einen Button, der nur 'loading' setzt, ohne eine neue Anfrage, und Code ohne die Prüfung von response.ok: Dann wird die Antwort mit 500 als Liste von Produkten gelesen.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Props: Daten von oben · Bedingungen: leer, Fehler, lädt · Zustand · Effekte
- Geht weiter in: Formulare in einer Komponente · Gemeinsamer Zustand · Komponenten testen · Von der Sandbox zum Projekt
- Aufgaben zum Thema: Effekte, Daten und Formulare