Daten von einer API in einer Komponente
Kurz gesagt: Daten von einer API werden in einer Vue-Komponente aus watch 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.
watch(() => props.categoryId, async (categoryId) => { status.value = 'loading'; try { const response = await fetch(`/api/products?category=${categoryId}`); if (!response.ok) throw new Error(`HTTP ${response.status}`); products.value = await response.json(); status.value = 'ready'; } catch (e) { status.value = 'error'; }}, { immediate: true });Die Funktion von watch darf async sein. { immediate: true } lädt die erste Kategorie, sobald die Komponente erstellt ist, und danach schickt jede Änderung von categoryId eine neue Anfrage.
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.vue<script setup>
import { ref, computed } from 'vue';
import Catalog from './Catalog.vue';
const CATEGORIES = [
{ id: 4, name: 'Milchprodukte' },
{ id: 5, name: 'Obst und Gemüse' },
{ id: 6, name: 'Getränke' },
];
const categoryId = ref(4);
const category = computed(() => CATEGORIES.find((c) => c.id === categoryId.value));
</script>
<template>
<main>
<nav aria-label="Kategorien">
<button
v-for="c in CATEGORIES"
:key="c.id"
type="button"
:aria-pressed="c.id === categoryId"
@click="categoryId = c.id"
>
{{ c.name }}
</button>
</nav>
<h1>{{ category.name }}</h1>
<Catalog :category-id="categoryId" />
</main>
</template>
Catalog.vue<script setup>
import { ref, watch } from 'vue';
import { money } from './money.js';
const props = defineProps(['categoryId']);
const products = ref([]);
const status = ref('loading');
watch(() => props.categoryId, async (categoryId) => {
status.value = 'loading';
try {
const response = await fetch(`/api/products?category=${categoryId}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
products.value = await response.json();
status.value = 'ready';
} catch (e) {
status.value = 'error';
}
}, { immediate: true });
</script>
<template>
<p v-if="status === 'loading'" role="status">Wird geladen…</p>
<p v-else-if="status === 'error'" role="alert">Die Produkte wurden nicht geladen.</p>
<ul v-else>
<li v-for="p in products" :key="p.id">{{ p.name }}, {{ money(p.price) }}</li>
</ul>
</template>
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; }
watch(() => props.categoryId, async (categoryId) => { … }, { immediate: true }) setzt 'loading', wartet auf die Antwort und setzt die Produkte und 'ready'. Die Prüfung erkennt watch ohne immediate: Dann bleibt die erste Kategorie für immer bei „Wird geladen…“.
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.
watch(() => props.categoryId, async (categoryId, oldId, onCleanup) => { const controller = new AbortController(); onCleanup(() => controller.abort()); status.value = '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}`); products.value = await response.json(); status.value = 'ready'; } catch (e) { if (e.name === 'AbortError') return; status.value = 'error'; }}, { immediate: true });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.
watch(() => props.categoryId, async (categoryId, oldId, onCleanup) => { const controller = new AbortController(); onCleanup(() => controller.abort()); status.value = 'loading'; try { const response = await fetch(url, { signal: controller.signal }); … } catch (e) { if (e.name === 'AbortError') return; status.value = 'error'; }}, { immediate: true });onCleanup steht vor dem ersten await. Steht es danach, wird das Aufräumen erst festgehalten, wenn die Antwort schon da ist, und es gibt nichts mehr zu stoppen.
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.vue<script setup>
import { ref, computed } from 'vue';
import Catalog from './Catalog.vue';
const CATEGORIES = [
{ id: 4, name: 'Milchprodukte' },
{ id: 5, name: 'Obst und Gemüse' },
{ id: 6, name: 'Getränke' },
];
const categoryId = ref(4);
const category = computed(() => CATEGORIES.find((c) => c.id === categoryId.value));
</script>
<template>
<main>
<nav aria-label="Kategorien">
<button
v-for="c in CATEGORIES"
:key="c.id"
type="button"
:aria-pressed="c.id === categoryId"
@click="categoryId = c.id"
>
{{ c.name }}
</button>
</nav>
<h1>{{ category.name }}</h1>
<Catalog :category-id="categoryId" />
</main>
</template>
Catalog.vue<script setup>
import { ref, watch } from 'vue';
import { money } from './money.js';
const props = defineProps(['categoryId']);
const products = ref([]);
const status = ref('loading');
watch(() => props.categoryId, async (categoryId, oldId, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
status.value = 'loading';
try {
const response = await fetch(`/api/products?category=${categoryId}`, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
products.value = await response.json();
status.value = 'ready';
} catch (e) {
if (e.name === 'AbortError') return;
status.value = 'error';
}
}, { immediate: true });
</script>
<template>
<p v-if="status === 'loading'" role="status">Wird geladen…</p>
<p v-else-if="status === 'error'" role="alert">Die Produkte wurden nicht geladen.</p>
<ul v-else>
<li v-for="p in products" :key="p.id">{{ p.name }}, {{ money(p.price) }}</li>
</ul>
</template>
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 hält onCleanup(() => controller.abort()) fest. 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?
watch(() => props.categoryId, (categoryId, oldId, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.());
fetch(url, { signal: controller.signal }).then(show);
}, { immediate: true });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 = ref(0);
watch([() => props.categoryId, attempt], async ([categoryId], old, onCleanup) => { // … dasselbe Laden}, { immediate: true });<button type="button" @click="attempt++">Noch einmal versuchen</button>Die Quelle ist ein Array: watch läuft, wenn sich eins von beiden ändert. Die Funktion bekommt ein Array mit den Werten.
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 Vue sind das useFetch aus VueUse, Pinia Colada und TanStack Query für Vue. 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.
<script setup> stoppt von selbst, wenn die Komponente verschwindet, und ruft das letzte onCleanup auf. Eine Anfrage, die noch unterwegs ist, wird also auch abgebrochen, wenn der Kunde die Seite verlässt.Ü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.vue<script setup>
import { ref, computed } from 'vue';
import Catalog from './Catalog.vue';
const CATEGORIES = [
{ id: 4, name: 'Milchprodukte' },
{ id: 5, name: 'Obst und Gemüse' },
{ id: 6, name: 'Getränke' },
];
const categoryId = ref(4);
const category = computed(() => CATEGORIES.find((c) => c.id === categoryId.value));
</script>
<template>
<main>
<nav aria-label="Kategorien">
<button
v-for="c in CATEGORIES"
:key="c.id"
type="button"
:aria-pressed="c.id === categoryId"
@click="categoryId = c.id"
>
{{ c.name }}
</button>
</nav>
<h1>{{ category.name }}</h1>
<Catalog :category-id="categoryId" />
</main>
</template>
Catalog.vue<script setup>
import { ref, watch } from 'vue';
import { money } from './money.js';
const props = defineProps(['categoryId']);
const products = ref([]);
const status = ref('loading');
const attempt = ref(0);
watch([() => props.categoryId, attempt], async ([categoryId], old, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
status.value = 'loading';
try {
const response = await fetch(`/api/products?category=${categoryId}`, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
products.value = await response.json();
status.value = 'ready';
} catch (e) {
if (e.name === 'AbortError') return;
status.value = 'error';
}
}, { immediate: true });
</script>
<template>
<p v-if="status === 'loading'" role="status">Wird geladen…</p>
<template v-else-if="status === 'error'">
<p role="alert">Die Produkte wurden nicht geladen.</p>
<button type="button" @click="attempt++">Noch einmal versuchen</button>
</template>
<ul v-else>
<li v-for="p in products" :key="p.id">{{ p.name }}, {{ money(p.price) }}</li>
</ul>
</template>
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 = ref(0), die Quelle von watch wird [() => props.categoryId, attempt], die Funktion bekommt [categoryId], und bei 'error' zeigt das Template die Meldung und einen Button mit @click="attempt++". Die Prüfung erkennt einen Button, der attempt ändert, während attempt in der Quelle 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