10. Serverzustand im Frontend
Serverzustand im Frontend
Abschnitt betitelt „Serverzustand im Frontend“Der Abruf von Daten im Frontend ist eine der Aufgaben, die in der Übung trivial aussehen und in der laufenden Anwendung ständig unangenehm auffallen. Das Muster aus SEW mit useState, useEffect und fetch funktioniert im Beispiel mit einer Liste. Es zerbricht, sobald zwei Komponenten dieselben Daten brauchen, sobald jemand schnell klickt und sobald jemand einen Datensatz ändert, den ein anderer offen hat.
Dieses Kapitel zeigt zuerst, woran genau es zerbricht, und dann, was an seine Stelle tritt.
Zwei Arten von Zustand
Abschnitt betitelt „Zwei Arten von Zustand“Der entscheidende Gedanke steht am Anfang und trägt das ganze Kapitel: Daten aus dem Netz sind kein Zustand wie jeder andere.
Client-Zustand gehört Ihrer Anwendung. Ob ein Menü offen ist, welcher Reiter gewählt wurde, was gerade im Suchfeld steht: Sie erzeugen ihn, Sie ändern ihn, er ist immer aktuell, und niemand sonst rührt ihn an.
Server-Zustand ist geliehen. Die Buchungsliste gehört dem Server, nicht Ihrer Komponente. Sie ist möglicherweise schon veraltet, während sie über die Leitung geht, sie kann von einer anderen Person gerade geändert werden, ihr Abruf kann langsam sein oder scheitern, und Ihre Anwendung erfährt davon nichts, solange sie nicht nachfragt.
| Client-Zustand | Server-Zustand | |
|---|---|---|
| Eigentümer | die Anwendung | jemand anderer |
| Aktualität | immer aktuell | möglicherweise veraltet |
| Zugriff | sofort | asynchron, kann scheitern |
| Änderung durch andere | nein | jederzeit |
| Braucht | einen Zustandshaken | Cache, Aktualisierung, Wiederholung |
Aus dieser Tabelle folgt der ganze Rest. Zwischenspeicherung, Aktualitätsprüfung, Wiederholung bei Netzfehlern und der Umgang mit gleichzeitigen Änderungen sind keine Zusatzfunktionen, sondern das Wesen der Sache. useEffect löst davon nichts.
Woran das naive Muster zerbricht
Abschnitt betitelt „Woran das naive Muster zerbricht“const [bookings, setBookings] = useState([]);const [isLoading, setIsLoading] = useState(true);const [error, setError] = useState(null);
useEffect(() => { setIsLoading(true); fetch(`/api/machines/${machineId}/bookings`) .then((r) => r.json()) .then(setBookings) .catch(setError) .finally(() => setIsLoading(false));}, [machineId]);Dieser Code ist nicht falsch geschrieben, er ist unvollständig, und zwar an sechs Stellen.
Die unangenehmste ist die Wettlaufsituation. Jemand klickt Maschine 7 an, die Antwort braucht 800 Millisekunden. Nach 200 Millisekunden klickt er Maschine 9 an, deren Antwort in 100 Millisekunden da ist. Die Anzeige zeigt kurz die richtigen Daten für Maschine 9, und dann trifft die verspätete Antwort für Maschine 7 ein und überschreibt sie. Auf dem Bildschirm steht Maschine 9, in der Liste stehen die Buchungen von Maschine 7.
sequenceDiagram participant U as Oberfläche participant S as Server U->>S: Buchungen für Maschine 7 U->>S: Buchungen für Maschine 9 S->>U: Antwort 9 (schnell) Note over U: zeigt Maschine 9, korrekt S->>U: Antwort 7 (langsam) Note over U: überschreibt mit falschen Daten
Dazu kommen fünf weitere Lücken. Beim Verlassen der Seite wird die Anfrage nicht abgebrochen. Zwei Komponenten, die dieselben Daten brauchen, holen sie zweimal. Beim Zurücknavigieren ist alles weg und wird neu geladen, weil es keinen Zwischenspeicher gibt. Lade- und Fehlerzustände werden in jeder Komponente von Hand nachgebaut. Und wenn jemand den Tab eine Stunde offen liegen lässt, zeigt die Anwendung eine Stunde alte Daten, ohne das zu wissen.
Man kann all das selbst lösen. Am Ende hat man eine eigene, schlechtere Version der Bibliothek, um die es in diesem Kapitel geht.
Grundlagen
Abschnitt betitelt „Grundlagen“// once in the applicationconst queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, retry: 2 } },});
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>function Schedule({ machineId }) { const { data, isPending, isError, error, isFetching } = useQuery({ queryKey: ["machines", machineId, "bookings"], queryFn: ({ signal }) => api.fetchBookings(machineId, { signal }), });
if (isPending) return <Skeleton />; if (isError) return <ErrorMessage error={error} />; return <BookingList bookings={data} refreshing={isFetching} />;}Die sechs Probleme von oben sind damit erledigt: Der signal bricht überholte Anfragen ab, gleiche Schlüssel werden zusammengefasst, das Ergebnis liegt im Cache, und die Zustände kommen mitgeliefert.
Query Keys sind Adressen
Abschnitt betitelt „Query Keys sind Adressen“Der Schlüssel ist die Adresse der Daten im Cache. Er ist ein Array, und seine Struktur entscheidet später darüber, wie gezielt Sie invalidieren können.
export const keys = { machines: ["machines"] as const, machine: (id: number) => ["machines", id] as const, bookings: (id: number) => ["machines", id, "bookings"] as const, bookingsFiltered: (id: number, filter: Filter) => ["machines", id, "bookings", filter] as const,};Der Nutzen dieser Hierarchie zeigt sich beim Invalidieren: ["machines", 7] trifft alles, was mit Maschine 7 zu tun hat, einschließlich ihrer Buchungen und aller gefilterten Varianten. Wer die Schlüssel dagegen als Zeichenkette zusammenbaut ("bookings-7-open"), hat keine Hierarchie und invalidiert am Ende immer alles.
Alles, was das Ergebnis beeinflusst, gehört in den Schlüssel. Ein Filter, der in der Abfragefunktion verwendet, aber nicht im Schlüssel steht, führt dazu, dass zwei verschiedene Ergebnisse an derselben Adresse liegen.
Aktualität: staleTime und gcTime
Abschnitt betitelt „Aktualität: staleTime und gcTime“Diese beiden Einstellungen werden am häufigsten verwechselt.
staleTime | gcTime | |
|---|---|---|
| Bedeutung | wie lange die Daten als frisch gelten | wie lange sie im Speicher bleiben, wenn niemand sie benutzt |
| Vorgabe | 0 | 5 Minuten |
| Wirkung | bestimmt, wann neu geholt wird | bestimmt, wann aufgeräumt wird |
Mit staleTime: 0 gelten Daten sofort als veraltet, und jedes erneute Einbinden holt neu. Das ist sicher und erzeugt viel Netzverkehr. Für Daten, die sich selten ändern, etwa die Maschinenliste, sind fünf Minuten angemessen; für eine Belegungsansicht, an der mehrere gleichzeitig arbeiten, eher zehn Sekunden.
Dazwischen liegt das Verhalten, das dem Ganzen seinen Namen gibt: stale-while-revalidate. Veraltete Daten werden sofort angezeigt und im Hintergrund erneuert. Der Benutzer sieht nie einen leeren Bildschirm, sondern höchstens kurz einen alten Wert. Genau dafür gibt es isFetching neben isPending: Das eine heißt „es wird gerade geholt”, das andere „es ist noch nichts da”.
Zusätzlich holt Query von sich aus neu, wenn das Fenster wieder den Fokus bekommt oder die Netzwerkverbindung zurückkommt. Das erledigt den Ein-Stunden-Tab aus dem vorigen Abschnitt.
Mutationen
Abschnitt betitelt „Mutationen“Schreibende Aufrufe laufen über useMutation. Der interessante Teil ist nicht das Schreiben, sondern die Frage, welche zwischengespeicherten Daten danach nicht mehr stimmen.
const { mutate, isPending } = useMutation({ mutationFn: (input) => api.createBooking(input), onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: keys.bookings(variables.machineId) }); },});Die Zeile mit invalidateQueries ersetzt das, was in vielen Schulprojekten ein window.location.reload() erledigt. Der Unterschied: Es wird genau das erneuert, was betroffen ist, und der Rest bleibt stehen.
Die Versuchung, stattdessen queryClient.invalidateQueries() ohne Schlüssel aufzurufen, ist groß, weil es immer funktioniert. Es macht allerdings den Cache sinnlos: Nach jeder kleinen Änderung wird die ganze Anwendung neu geladen.
Optimistische Aktualisierung
Abschnitt betitelt „Optimistische Aktualisierung“Bei Aktionen, die fast immer gelingen, kann die Oberfläche das Ergebnis vorwegnehmen und im Fehlerfall zurückrollen. Das ist der Unterschied zwischen „Häkchen erscheint sofort” und „Häkchen erscheint nach 400 Millisekunden Kreisel”.
useMutation({ mutationFn: (id) => api.cancelBooking(id),
onMutate: async (id) => { await queryClient.cancelQueries({ queryKey: keys.bookings(machineId) }); const previous = queryClient.getQueryData(keys.bookings(machineId));
queryClient.setQueryData(keys.bookings(machineId), (old) => old.filter((b) => b.id !== id), );
return { previous }; // rollback point },
onError: (_error, _id, context) => { queryClient.setQueryData(keys.bookings(machineId), context.previous); },
onSettled: () => { queryClient.invalidateQueries({ queryKey: keys.bookings(machineId) }); },});Vier Schritte, die zusammengehören: laufende Abfragen abbrechen, damit keine alte Antwort das optimistische Ergebnis überschreibt; den bisherigen Stand sichern; die Anzeige ändern; im Fehlerfall zurücksetzen und am Ende in jedem Fall die Wahrheit vom Server holen.
Eine optimistische Aktualisierung ohne den onError-Zweig ist keine Optimierung, sondern eine Lüge: Der Benutzer sieht dann eine stornierte Buchung, die noch existiert.
Fortgeschrittene Muster
Abschnitt betitelt „Fortgeschrittene Muster“Abhängige Abfragen warten auf ein Ergebnis: enabled: !!machineId verhindert, dass geladen wird, bevor die Auswahl feststeht.
select leitet aus den Daten ab, ohne sie erneut zu holen, etwa eine gefilterte oder sortierte Sicht.
Infinite Queries setzen die Cursor-Paginierung aus Kapitel 7 im Frontend um:
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ queryKey: keys.bookings(machineId), queryFn: ({ pageParam, signal }) => api.fetchBookings(machineId, { after: pageParam, signal }), initialPageParam: undefined, getNextPageParam: (lastPage) => lastPage.next ?? undefined,});Der Rückgabewert naechste ist genau das Feld, das der Endpunkt in Kapitel 7 liefert. Wenn er null ist, gibt es keine weitere Seite, und hasNextPage wird falsch.
Vorabruf lädt Daten, bevor sie gebraucht werden, etwa beim Überfahren eines Verweises mit der Maus. Der Detailaufruf wirkt danach verzögerungsfrei.
Wiederholungen brauchen eine Regel. Ein Netzfehler oder ein 500er lohnt einen zweiten Versuch mit wachsendem Abstand. Ein 400er oder 403er lohnt keinen: Die Anfrage war falsch und bleibt falsch. Ein 429 aus der Ratenbegrenzung von Kapitel 7 gehört genau so lange respektiert, wie Retry-After sagt.
retry: (attempt, error) => error.status >= 500 && attempt < 3,Zusammenspiel mit Next.js
Abschnitt betitelt „Zusammenspiel mit Next.js“Server Components holen ihre Daten auf dem Server, bevor die Seite ausgeliefert wird. Damit stellt sich die berechtigte Frage, wozu man einen Client-Cache überhaupt braucht. Die Antwort ist eine Aufteilung:
| Art der Daten | Wohin |
|---|---|
| selten wechselnd, für alle gleich | Server Component |
| suchmaschinenrelevant | Server Component |
| benutzerbezogen und interaktiv | Client mit Query |
| häufig wechselnd, mehrere Betrachter | Client mit Query |
| nach einer Mutation sofort sichtbar | Client mit Query |
Eine öffentliche Maschinenliste kommt vom Server, die Belegungsansicht mit Buchen und Stornieren läuft über den Cache. Beides lässt sich verbinden: Der Server lädt vor, übergibt den Stand an den Client, und dort läuft es normal weiter.
// prefetch on the server and hand it overawait queryClient.prefetchQuery({ queryKey: keys.bookings(id), queryFn: () => bookingService.list(id),});
<HydrationBoundary state={dehydrate(queryClient)}> <Schedule machineId={id} /></HydrationBoundary>Zur Einordnung: SWR löst dieselbe Aufgabe mit weniger Umfang und weniger Möglichkeiten bei Mutationen. RTK Query gehört zu Redux Toolkit und lohnt sich, wenn ohnehin Redux im Einsatz ist. Die Konzepte, um die es hier geht, sind bei allen dreien dieselben, und genau die sind der Prüfungsstoff, nicht die Schreibweise.
Lade- und Fehlerzustände
Abschnitt betitelt „Lade- und Fehlerzustände“Jede Ansicht mit Serverdaten hat vier Zustände, und drei davon werden regelmäßig vergessen: Es lädt, es ist ein Fehler aufgetreten, es sind keine Daten vorhanden, es sind Daten vorhanden.
Ein Skelett, das die spätere Struktur andeutet, wirkt schneller als ein Kreisel in der Mitte, weil der Aufbau der Seite nicht springt. Ein Fehlerzustand braucht eine Schaltfläche zum Wiederholen und einen Satz, der sagt, was passiert ist. Ein Leerzustand ist eine eigene Gestaltungsaufgabe: „Noch keine Buchungen. Jetzt eine anlegen.” ist brauchbar, eine leere Fläche nicht. Dieser Punkt wird in Kapitel 15 vertieft, gehört aber schon hier in jede Komponente.
Häufige Stolperfallen
Abschnitt betitelt „Häufige Stolperfallen“- Query Keys als Zeichenkette zusammenbauen. Ohne Hierarchie lässt sich nicht gezielt invalidieren.
- Etwas im Schlüssel vergessen, das die Abfrage beeinflusst. Zwei Ergebnisse liegen dann an derselben Adresse.
- Nach jeder Mutation alles invalidieren. Funktioniert und macht den Cache wertlos.
- Serverdaten zusätzlich in
useStatekopieren. Ab da gibt es zwei Wahrheiten, und sie laufen auseinander. - Überall
staleTime: 0und sich anschließend über die Netzwerklast wundern. - Optimistische Aktualisierung ohne Rückrollpfad. Die Oberfläche zeigt dann etwas, das nicht passiert ist.
- Bei 4xx wiederholen. Drei Versuche mit demselben falschen Ergebnis.
- Leer- und Fehlerzustand nicht gestalten. Die Anwendung wirkt kaputt, obwohl sie funktioniert.
Lernergebnisse
Abschnitt betitelt „Lernergebnisse“- Erklären: Server- und Client-Zustand unterscheiden und Beispiele aus dem eigenen Projekt zuordnen.
- Analysieren: die Schwächen des Abrufs mit
useEffectbenennen und eine Wettlaufsituation erklären. - Anwenden: Abfragen mit hierarchischen Schlüsseln umsetzen und Lade-, Fehler- und Aktualisierungszustände unterscheiden.
- Erklären:
staleTimeundgcTimeunterscheiden und für verschiedene Datenarten begründet festlegen. - Anwenden: Mutationen mit gezielter Invalidierung der betroffenen Schlüssel umsetzen.
- Erschaffen: eine optimistische Aktualisierung mit vollständigem Rückrollpfad implementieren.
- Anwenden: die Cursor-Paginierung aus Kapitel 7 über eine Infinite Query anbinden.
- Beurteilen: entscheiden, welche Daten in einer Server Component und welche über den Client-Cache geladen werden.
- Anwenden: eine Wiederholungsstrategie festlegen, die zwischen Client- und Serverfehlern unterscheidet.
- Erschaffen: Lade-, Fehler- und Leerzustände als Teil der Ansicht gestalten.
Arbeit am Jahresprojekt
Abschnitt betitelt „Arbeit am Jahresprojekt“- Alle Listen- und Detailansichten laufen über den Query-Cache, mit einer Schlüsseldatei an einer Stelle im Projekt.
- Mutationen invalidieren gezielt, ein Neuladen der Seite kommt im Code nicht mehr vor.
- Mindestens eine Aktion ist optimistisch umgesetzt, samt Rückrollen bei Fehler; der Fehlerfall ist einmal vorgeführt.
- Eine Liste ist als Endlosliste über die Cursor-Paginierung angebunden.
- Jede Ansicht hat einen gestalteten Lade-, Fehler- und Leerzustand.
- In
docs/architektur.mdsteht, welche Ansichten serverseitig und welche über den Cache geladen werden, mit Begründung.
Passende Übungen
Abschnitt betitelt „Passende Übungen“- Aufgabe - Vom useEffect-Abruf zur Query
- Aufgabe - Wettlaufsituation nachstellen und auflösen
- Aufgabe - Optimistisches Löschen mit Rückrollen
- Aufgabe - Endlosliste mit Cursor-Paginierung