Daten aus einer API
Kurz gesagt: fetch holt mit async/await Daten aus einer API, prüft response.ok und liest das JSON, und die Seite zeigt einen von vier Zuständen: Laden, Fehler, leer oder Daten.
Problem
Ein leeres Schaufenster
Die Seite mit den Milchprodukten holt ihre Produkte vom Server. Heute hat der Server ein Problem und antwortet mit dem Fehler 500. Die Käuferin sieht eine Überschrift und darunter nichts.
Konsole
Die Konsole meldet products.map is not a function. Der Code hat die Antwort des Fehlers, { "error": "…" }, für eine Liste von Produkten gehalten. Die Käuferin sieht keinen Fehler und kein „Nochmal versuchen“. Und selbst wenn der Server funktioniert, ist die Seite leer, solange die Antwort unterwegs ist.
Die Idee
fetch auf der Seite
fetch(address) schickt eine Anfrage und gibt ein Versprechen (Promise) auf die Antwort zurück. Mit await wartet die Funktion auf die Antwort, ohne die Seite anzuhalten:
const response = await fetch('/api/products?category=4');if (!response.ok) throw new Error(`HTTP ${response.status}`);const products = await response.json();fetchwirft nur, wenn es keine Antwort gibt: kein Netz. Eine Antwort mit 404 oder 500 ist eine Antwort, und das Promise wird erfüllt.response.okist nur für Codes von 200 bis 299 wahr. Das zu prüfen ist deine Aufgabe.response.json()liest den Body und macht daraus ein Objekt. Auch das ist ein Promise, also bekommt es ebenfallsawait.
In der Sandbox beantwortet die Schein-API des Shops die Adressen /api/…, ganz ohne Netz: GET /api/products (mit ?q= und ?category=), /api/products/3, /api/categories, /api/orders und POST /api/orders. Eine Lektion kann sie langsam machen, mit 500 antworten lassen oder eine leere Liste zurückgeben lassen. So ist jeder Zustand echt und immer derselbe.
Die Idee
Vier Zustände
Eine Anfrage braucht Zeit und hat ein Ergebnis. Der Bildschirm muss in jedem Moment sagen, was gerade los ist:
- Laden: Die Anfrage ist unterwegs, es gibt noch keine Antwort;
- Daten: Eine Liste von Produkten ist angekommen;
- leer: Eine Liste ist angekommen, aber sie ist leer. Das ist kein Fehler;
- Fehler: Es gibt keine Antwort, oder die Antwort ist nicht
ok.
Jeder Zustand ist ein Element im HTML. Der Code zeigt und versteckt die Elemente mit der Property hidden:
<p id="loading" role="status">Wird geladen…</p><p id="error" role="alert" hidden></p><p id="empty" hidden>Keine Produkte in dieser Kategorie.</p><ul id="products"></ul>„Wird geladen…“ ist von Anfang an sichtbar, noch bevor JavaScript überhaupt läuft. role="status" sorgt dafür, dass ein Screenreader es vorliest, und role="alert" liest den Fehler vor, sobald er erscheint.
Sieh es dir an
Eine Anfrage beobachten
async function load() { try { const response = await fetch('/api/products?category=4'); if (!response.ok) throw new Error(`HTTP ${response.status}`); const products = await response.json(); empty.hidden = products.length > 0; list.replaceChildren(...products.map(productItem)); } catch { error.textContent = 'Die Produkte wurden nicht geladen.'; error.hidden = false; } finally { loading.hidden = true; }}Schritt für Schritt, in Worten
- load() schickt die Anfrage los. Eine Antwort gibt es noch nicht: Der Bildschirm zeigt „Wird geladen…“ aus dem HTML.
- await wartet, aber die Seite friert nicht ein: Die Käuferin kann scrollen, und der Spinner dreht sich. Bei einem langsamen Netz dauert das Sekunden.
- 200 mit einem Array: ok ist true, json() liefert die Produkte, die Zeilen werden gezeichnet, und finally versteckt „Wird geladen…“.
- Dieselbe Anfrage, Antwort 500. fetch wirft nichts: Es hat eine Antwort bekommen. Unsere Prüfung von ok wirft, und catch zeigt den Fehler.
- 200 mit []: Das ist kein Fehler. Das Array ist leer, deshalb sagt die Seite „keine Produkte“, statt eine Leerstelle zu zeigen.
- Vier Zustände, jeder sichtbar: Laden, Daten, Fehler, leer. finally versteckt „Wird geladen…“ bei jedem Ausgang aus try.
Derselbe Code, drei verschiedene Antworten. Bei 500 wirft fetch nichts: Die Prüfung von ok wirft, und catch zeigt den Fehler. finally versteckt „Wird geladen…“ in allen drei Fällen.
Sieh es dir an
Die langsame API
Hier antwortet die API nach 1,2 Sekunden. Klick auf „Ausführen“ und beobachte „Wird geladen…“.
Konsole
Der ganze Code zum Laden:
async function load() { try { const response = await fetch('/api/products?category=4'); if (!response.ok) throw new Error(`HTTP ${response.status}`); const products = await response.json(); empty.hidden = products.length > 0; list.replaceChildren(...products.map(productItem)); } catch { error.textContent = 'Die Produkte wurden nicht geladen.'; error.hidden = false; } finally { loading.hidden = true; }}
load();Probier es aus
Fehler
Hier bekommt die erste Anfrage eine 500, und die zweite geht durch. Bei einem Fehler soll die Seite „Die Produkte wurden nicht geladen.“ in #error und den Button „Nochmal versuchen“ zeigen, „Wird geladen…“ soll verschwinden, und „keine Produkte“ darf nicht erscheinen. Der Button lädt schon wieder neu: Danach müssen die Produkte auf der Seite stehen.
Konsole
Lösung
index.html<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Milchprodukte</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<h1>Milchprodukte</h1>
<p id="loading" role="status">Wird geladen…</p>
<p id="error" role="alert" hidden></p>
<button id="retry" hidden>Nochmal versuchen</button>
<p id="empty" hidden>Keine Produkte in dieser Kategorie.</p>
<ul id="products"></ul>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; margin: 0 16px; }
li { margin: 4px 0; }
#error { color: #b3261e; }
label { display: block; margin-top: 8px; }
.error { color: #b3261e; margin: 2px 0 0; min-height: 1.2em; }
button { font: inherit; margin-top: 12px; }
script.jsconst loading = document.querySelector('#loading');
const error = document.querySelector('#error');
const empty = document.querySelector('#empty');
const list = document.querySelector('#products');
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
function productItem(product) {
const li = document.createElement('li');
li.textContent = `${product.name}, ${money(product.price)}`;
return li;
}
const retry = document.querySelector('#retry');
retry.addEventListener('click', () => {
retry.hidden = true;
error.hidden = true;
loading.hidden = false;
load();
});
async function load() {
try {
const response = await fetch('/api/products?category=4');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const products = await response.json();
empty.hidden = products.length > 0;
list.replaceChildren(...products.map(productItem));
} catch {
error.textContent = 'Die Produkte wurden nicht geladen.';
error.hidden = false;
retry.hidden = false;
} finally {
loading.hidden = true;
}
}
load();
Die Prüfung von response.ok wirft bei 500, catch schreibt die Meldung und zeigt #error und den Button, und finally versteckt „Wird geladen…“. Ohne die Prüfung liest der Code { "error": "…" } als Produkte: products.length ist undefined, und undefined > 0 ist falsch, also erscheint „keine Produkte“, und das stimmt nicht. Die Prüfung klickt auf „Nochmal versuchen“, denn eine Seite, die immer den Fehler zeigt, würde auch die erste Hälfte bestehen.
Probier es aus
Leer
Hier gibt die API eine leere Liste zurück. Zeige „Keine Produkte in dieser Kategorie.“, wenn es null Produkte sind, und verstecke die Meldung, wenn es welche gibt.
Konsole
Lösung
index.html<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Milchprodukte</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<h1>Milchprodukte</h1>
<p id="loading" role="status">Wird geladen…</p>
<p id="error" role="alert" hidden></p>
<p id="empty" hidden>Keine Produkte in dieser Kategorie.</p>
<ul id="products"></ul>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; margin: 0 16px; }
li { margin: 4px 0; }
#error { color: #b3261e; }
label { display: block; margin-top: 8px; }
.error { color: #b3261e; margin: 2px 0 0; min-height: 1.2em; }
button { font: inherit; margin-top: 12px; }
script.jsconst loading = document.querySelector('#loading');
const error = document.querySelector('#error');
const empty = document.querySelector('#empty');
const list = document.querySelector('#products');
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
function productItem(product) {
const li = document.createElement('li');
li.textContent = `${product.name}, ${money(product.price)}`;
return li;
}
async function load() {
try {
const response = await fetch('/api/products?category=4');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const products = await response.json();
empty.hidden = products.length > 0;
list.replaceChildren(...products.map(productItem));
} catch {
error.textContent = 'Die Produkte wurden nicht geladen.';
error.hidden = false;
} finally {
loading.hidden = true;
}
}
load();
empty.hidden = products.length > 0;: Die Meldung ist genau dann versteckt, wenn es mindestens ein Produkt gibt. if (!products) funktioniert nicht: Ein leeres Array ist „wahr“, und !products ist falsch.
Probier es aus
Finde den Fehler
Der Server antwortet mit 200 und Produkten, und die Seite zeigt einen Fehler.
Wähle die Zeile mit dem Fehler.
In Zeile 5 fehlt await. response.json() gibt ein Promise zurück, kein Array. Ein Promise hat kein map, also wirft Zeile 6, und catch zeigt einen Fehler, obwohl der Server geantwortet hat.
Die Idee
Senden: der Button und das Warten
Die Bestellung geht mit POST raus. Die Daten stehen als JSON im Body, und der Header Content-Type sagt dem Server, was er bekommt:
const response = await fetch('/api/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...data, items }),});data ist das Objekt aus den Feldern des Formulars: Object.fromEntries(new FormData(form)) macht aus den Paaren name=Wert ein Objekt { name: …, email: …, address: … }.
Während die Antwort unterwegs ist, ist der Button „Bestellen“ noch aktiviert. Eine ungeduldige Käuferin drückt zweimal:
Konsole
Zwei Anfragen, zwei Bestellungen: 61 und 62. Die Seite zeigt die letzte, und niemand merkt, dass auch die erste angenommen wurde.
Probier es aus
Ein Klick, eine Bestellung
Deaktiviere den Button „Bestellen“, bevor die Anfrage losgeht, und aktiviere ihn wieder, wenn die Antwort kommt, auch wenn es ein Fehler ist. Die Prüfung klickt schnell zweimal und erwartet eine Bestellung: 61.
Konsole
Lösung
index.html<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Schnellbestellung</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<h1>Schnellbestellung</h1>
<p>Weißbrot × 2, Frischmilch × 1</p>
<form id="order">
<label>Name <input name="name" value="Lena Fischer"></label>
<label>E-Mail <input name="email" value="lena@example.com"></label>
<label>Adresse <input name="address" value="Schillerstraße 7"></label>
<button>Bestellen</button>
</form>
<p id="result" role="status"></p>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; margin: 0 16px; }
li { margin: 4px 0; }
#error { color: #b3261e; }
label { display: block; margin-top: 8px; }
.error { color: #b3261e; margin: 2px 0 0; min-height: 1.2em; }
button { font: inherit; margin-top: 12px; }
script.jsconst items = [{ productId: 1, qty: 2 }, { productId: 5, qty: 1 }];
const form = document.querySelector('#order');
const button = form.querySelector('button');
const result = document.querySelector('#result');
form.addEventListener('submit', async (event) => {
event.preventDefault();
button.disabled = true;
try {
const data = Object.fromEntries(new FormData(form));
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...data, items }),
});
const order = await response.json();
result.textContent = `Bestellung ${order.id} ist angenommen.`;
} finally {
button.disabled = false;
}
});
button.disabled = true ist das Erste nach preventDefault(), vor await. Ein deaktivierter Button nimmt keinen Klick an und sendet das Formular nicht ab. finally { button.disabled = false; } aktiviert ihn nach Erfolg und nach einem Fehler wieder. Wird der Button erst nach await fetch(…) deaktiviert, ist der zweite Klick schon durchgegangen.
Genauer, für Neugierige
Ein Netzwerkfehler. Ohne Netz wirft fetch TypeError: Failed to fetch. catch fängt das und auch dein eigenes throw bei !response.ok: Für die Käuferin heißt beides „nicht geladen“.
Der Body eines Fehlers. Ein Server erklärt den Fehler oft im Body, als JSON. Bei 400 gibt die Schein-API { "errors": { "email": "…" } } zurück: einen Fehler pro Feld. Deshalb liest die Aufgabe den Body auch bei einem Fehler.
Nochmal versuchen. Ein Button „Nochmal versuchen“ neben dem Fehler ruft einfach noch einmal load() auf. Vorher muss „Wird geladen…“ wieder erscheinen und der Fehler verschwinden.
Ein Wettlauf zwischen Anfragen. Wechselt die Käuferin schnell zweimal die Kategorie, können die beiden Antworten in umgekehrter Reihenfolge ankommen, und die Seite zeigt die alte Kategorie. Wie du eine alte Anfrage abbrichst (AbortController), steht im Modul über Effekte.
Prüfungen im Browser und auf dem Server. required und type="email" halten das Formular auf, bevor es rausgeht. Der Server prüft trotzdem noch einmal, denn eine Anfrage kann auch ohne unsere Seite kommen. In der Aufgabe haben die Felder keine eingebauten Prüfungen, damit du siehst, was der Server antwortet.
Übung
Die Bestellung
Sende die Bestellung: POST /api/orders mit name, email und address aus dem Formular und items aus dem Array. Solange sie unterwegs ist, ist der Button deaktiviert. Setze bei 400 jede Meldung aus errors in den Absatz unter ihrem Feld (#email-error und so weiter) und lösche bei jedem neuen Senden die alten Meldungen. Schreibe bei Erfolg „Bestellung 61 ist angenommen. Gesamt: 4,00 €“ in #result (die Nummer und die Summe kommen aus der Antwort).
Konsole
Lösung
index.html<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Bestellung</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<h1>Bestellung</h1>
<p>Weißbrot × 2, Frischmilch × 1</p>
<p class="error" id="items-error"></p>
<form id="order">
<label>Name <input name="name"></label>
<p class="error" id="name-error"></p>
<label>E-Mail <input name="email"></label>
<p class="error" id="email-error"></p>
<label>Adresse <input name="address"></label>
<p class="error" id="address-error"></p>
<button>Bestellen</button>
</form>
<p id="result" role="status"></p>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; margin: 0 16px; }
li { margin: 4px 0; }
#error { color: #b3261e; }
label { display: block; margin-top: 8px; }
.error { color: #b3261e; margin: 2px 0 0; min-height: 1.2em; }
button { font: inherit; margin-top: 12px; }
script.jsconst items = [{ productId: 1, qty: 2 }, { productId: 5, qty: 1 }];
const form = document.querySelector('#order');
const button = form.querySelector('button');
const result = document.querySelector('#result');
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
form.addEventListener('submit', async (event) => {
event.preventDefault();
button.disabled = true;
result.textContent = '';
for (const p of document.querySelectorAll('.error')) p.textContent = '';
try {
const data = Object.fromEntries(new FormData(form));
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...data, items }),
});
const answer = await response.json();
if (response.status === 400) {
for (const [name, message] of Object.entries(answer.errors)) {
document.querySelector(`#${name}-error`).textContent = message;
}
} else if (response.ok) {
result.textContent = `Bestellung ${answer.id} ist angenommen. Gesamt: ${money(answer.total)}`;
} else {
result.textContent = 'Die Bestellung wurde nicht gesendet. Versuch es noch einmal.';
}
} finally {
button.disabled = false;
}
});
Der Server gibt 400 und { errors: { email: …, address: … } } zurück. Die Schleife über Object.entries(answer.errors) findet den Absatz #${name}-error und schreibt die Meldung hinein. Werden die alten Meldungen nicht gelöscht, bleibt nach der Korrektur „Die E-Mail-Adresse ist ungültig“ unter einem richtigen Feld stehen. Bei 201 ist die Antwort { id, status, total }: Die Nummer und die Summe kommen vom Server, nicht von der Seite.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Barrierefreiheit · Das DOM: die Seite aus JavaScript · Ereignisse · Eine Liste aus Daten
- Geht weiter in: Bedingungen: leer, Fehler, lädt · Daten von einer API in einer Komponente · Formulare in einer Komponente · Das Schaufenster des Shops
- Aufgaben zum Thema: JavaScript in der Seite