Suspense und der use-Hook: Laden als Grenze, nicht als Zustand
Statt in jeder Komponente einen Ladezustand zu verwalten, beschreibt man mit Suspense eine einzige Grenze im Baum – und der use-Hook lässt die Komponente darunter rendern, als wären die Daten längst da.
Suspense und der use-Hook: Laden als Grenze, nicht als Zustand
Im Beitrag über Client-Zustand und Server-Cache habe ich gezeigt, wie man Serverdaten aus dem Gebastel aus useState und useEffect herauslöst und einer Cache-Bibliothek überlässt. Am Ende stand ein Ausblick: React 19 verschiebt gerade noch etwas anderes, nämlich die Frage, wo im Baum das Warten eigentlich stattfindet. Diesen Faden nehme ich hier auf.
Denn eine Sache blieb selbst nach dem Umzug auf TanStack Query oder SWR bestehen: In jeder Komponente, die Daten braucht, steht irgendwo if (isLoading) return <Spinner /> und darunter if (error) return <ErrorMessage />. Drei mögliche Zustände – lädt, Fehler, Daten – und jede Komponente behandelt alle drei selbst. Suspense dreht das um. Das Warten wird von einer Eigenschaft jeder einzelnen Komponente zu einer Eigenschaft einer Grenze im Baum. Und die Komponente darunter darf so tun, als seien die Daten schon da.
Der Ladezustand wandert nach außen
Sehen wir uns zuerst an, wie es ohne Suspense aussieht. Eine Komponente, die eine Liste von Rechnungen anzeigt, trägt die drei Zustände selbst:
function InvoiceList() {
const { data, isLoading, error } = useInvoices();
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return (
<ul>
{data.map((invoice) => (
<li key={invoice.id}>{invoice.title}</li>
))}
</ul>
);
}
Das ist nicht schlimm, aber es wiederholt sich. Jede datenladende Komponente sieht so aus, jede hat ihren eigenen Spinner, ihre eigene Fehlerbehandlung. Und wenn drei solche Komponenten nebeneinander stehen, blitzen drei Spinner unabhängig voneinander auf und verschwinden zu unterschiedlichen Zeitpunkten – ein unruhiges Bild.
Suspense stellt dem eine andere Idee gegenüber. Man beschreibt einmal eine Grenze und sagt: Alles unterhalb dieser Grenze, das noch auf Daten wartet, soll so lange diesen Fallback zeigen.
<Suspense fallback={<Spinner />}>
<InvoiceList />
</Suspense>
Die InvoiceList selbst kennt jetzt keinen Ladezustand mehr. Sie fragt die Daten ab, und wenn die noch nicht da sind, hält React die Komponente an und zeigt stattdessen den Fallback der nächsten umschließenden <Suspense>-Grenze. Das Wort dafür ist suspendieren: Die Komponente pausiert mitten im Rendern, bis das Versprochene eintrifft. Der Fallback ist nicht mehr Sache der Komponente, sondern der Grenze über ihr.
Der use-Hook liest ein Promise
Damit eine Komponente überhaupt suspendieren kann, braucht sie eine Möglichkeit, ein noch offenes Versprechen zu „lesen". Genau das macht der use-Hook, der mit React 19 stabil ist. Man übergibt ihm ein Promise, und er gibt den aufgelösten Wert zurück – oder unterbricht das Rendern, wenn der Wert noch nicht da ist.
import { use } from "react";
function InvoiceList({ invoicesPromise }) {
const invoices = use(invoicesPromise);
return (
<ul>
{invoices.map((invoice) => (
<li key={invoice.id}>{invoice.title}</li>
))}
</ul>
);
}
Man liest diese Zeile fast wie synchronen Code: const invoices = use(invoicesPromise), danach steht invoices einfach zur Verfügung. Von der Zwischenzeit, in der das Promise noch offen ist, sieht die Komponente nichts. Ist das Promise noch nicht erfüllt, suspendiert use die Komponente, React hält an und zeigt den Fallback der umschließenden Grenze. Sobald das Promise erfüllt ist, rendert React die Komponente erneut, diesmal mit dem Wert in der Hand.
Neben Promises nimmt use übrigens auch einen Context entgegen und ersetzt in dieser Rolle useContext. Der interessante Fall für diesen Beitrag ist aber das Promise, weil daran das Zusammenspiel mit Suspense hängt.
Warum use bedingt aufgerufen werden darf
Hier lohnt sich ein kurzer Blick auf eine Besonderheit, die auf den ersten Blick wie ein Regelbruch aussieht. Für alle anderen Hooks gilt eine eiserne Regel: Sie müssen auf oberster Ebene der Komponente stehen, in immer derselben Reihenfolge, nie in einer Bedingung oder Schleife. Der Grund ist, dass React die Hooks pro Render über ihre Aufrufreihenfolge zuordnet – gerät die Reihenfolge durcheinander, verwechselt React die Zustände.
use ist ausdrücklich von dieser Regel ausgenommen. Man darf ihn in einer if-Bedingung aufrufen, in einer Schleife, hinter einem frühen return:
function Message({ show, messagePromise }) {
if (show) {
const message = use(messagePromise);
return <p>{message}</p>;
}
return null;
}
Das ist mehr als eine Bequemlichkeit. use speichert selbst keinen Zustand, der über eine feste Aufrufreihenfolge einer Komponente zugeordnet werden müsste – er liest nur einen Wert aus einem Objekt, das man ihm reicht. Deshalb kann er dort stehen, wo er inhaltlich hingehört, statt an den Anfang der Komponente gezwungen zu werden. Man ruft ein Promise genau dann ab, wenn man seinen Wert wirklich braucht.
Fehler werden zu einer zweiten Grenze
Bleibt der dritte Zustand von oben: der Fehler. Wenn das Promise nicht erfüllt, sondern abgelehnt wird, wirft use diesen Fehler beim Rendern. Und ein geworfener Fehler landet bei der nächsten Error Boundary – demselben Mechanismus, mit dem React seit jeher Render-Fehler auffängt.
<ErrorBoundary fallback={<ErrorMessage />}>
<Suspense fallback={<Spinner />}>
<InvoiceList invoicesPromise={invoicesPromise} />
</Suspense>
</ErrorBoundary>
Damit ist die Rechnung aufgegangen. Aus den drei Zuständen, die vorher jede Komponente selbst verwaltet hat – lädt, Fehler, Daten –, sind zwei Grenzen im Baum geworden plus der reine Erfolgsfall in der Komponente. Die <Suspense>-Grenze kümmert sich um das Warten, die <ErrorBoundary> um das Scheitern, und die Komponente selbst rendert nur noch den Fall, für den sie eigentlich da ist: die vorhandenen Daten. Das if (isLoading) und das if (error) sind ersatzlos verschwunden.
flowchart TB
R["Komponente rendert,<br/>ruft use(promise)"] --> Q{"Promise-Zustand?"}
Q -->|offen| S["suspendiert →<br/>Suspense zeigt Fallback"]
S -.->|Promise erfüllt| R
Q -->|erfüllt| OK["Inhalt erscheint<br/>mit den Daten"]
Q -->|abgelehnt| ERR["Fehler wird geworfen →<br/>ErrorBoundary fängt ihn"]
An dieser Stelle ist eine Error Boundary noch eine Klassenkomponente oder man greift zu einem kleinen Paket wie react-error-boundary – eine eingebaute Funktions-Variante gibt es in React 19 noch nicht. Das ändert am Bild aber nichts: konzeptionell ist es die zweite Grenze neben Suspense.
Ehrlich bleiben: use ist keine Cache-Bibliothek
Jetzt kommt der Teil, den man leicht überliest, weil die Beispiele oben so aufgeräumt wirken. Woher kommt eigentlich das invoicesPromise? In den Codeschnipseln habe ich es als Prop hereingereicht und die Frage damit verschoben. Sie ist aber die entscheidende.
Man könnte auf die Idee kommen, das Promise einfach in der Komponente zu erzeugen:
function InvoiceList() {
// Falle: erzeugt bei JEDEM Render ein neues Promise
const invoices = use(fetch("/api/invoices").then((r) => r.json()));
// ...
}
Das sieht kompakt aus und ist trotzdem falsch. Jeder Render-Durchlauf ruft fetch erneut auf und erzeugt ein frisches Promise. React sieht ein noch offenes Promise, suspendiert, rendert nach dem Auflösen neu – und erzeugt dabei sofort das nächste neue Promise. Man baut sich eine Endlosschleife aus Ladevorgängen. use cacht nichts, es dedupliziert nichts, es liest nur, was man ihm gibt.
Damit das funktioniert, muss das Promise von außerhalb des Renderns kommen: von jemandem, der es einmal erzeugt, unter einem Schlüssel ablegt und bei der nächsten Anfrage dasselbe Promise zurückgibt. Das ist genau die Buchhaltung – Schlüssel, Alter, Deduplizierung, Invalidierung –, die ich im Beitrag über den Server-Cache beschrieben habe. use und Suspense lösen sie nicht, sie setzen sie voraus.
In der Praxis liefern deshalb dieselben Werkzeuge die Promises, die man ohnehin schon einsetzt. TanStack Query und SWR haben beide einen Suspense-Modus: Statt isLoading und error zurückzugeben, suspendieren sie und werfen im Fehlerfall, sodass Grenzen die Zustände übernehmen. Oder ein Framework wie Next.js erzeugt die Promises auf dem Server und reicht sie hinein. Die Regel aus dem Cache-Beitrag gilt unverändert: Serverdaten brauchen einen Cache mit Schlüssel und Invalidierung. Suspense ändert nur, wie sich das Warten darauf im Baum anfühlt – nicht, dass jemand den Cache führen muss.
Wofür sich der Aufwand lohnt
Wenn use die Buchhaltung nicht abnimmt, warum dann der ganze Umbau? Der Gewinn liegt woanders, nämlich in der Koordination mehrerer Datenquellen unter einer Grenze.
Stellen wir uns eine Profilseite vor, die Nutzerdaten, die letzten Beiträge und eine Statistik lädt – drei Quellen. Ohne Suspense blitzen drei Spinner unabhängig auf und lösen sich zu drei Zeitpunkten auf; die Seite zuckt beim Aufbau. Legt man die drei Komponenten unter eine gemeinsame <Suspense>-Grenze, wartet React, bis alle drei so weit sind, und zeigt dann die fertige Seite in einem Stück. Ein Fallback statt drei, ein Moment des Erscheinens statt drei.
Genauso oft will man das Gegenteil: nicht auf die langsamste Quelle warten, sondern das Schnelle sofort zeigen. Dann verschachtelt man die Grenzen. Das Nutzerprofil steht unter einer äußeren Grenze und erscheint früh; die langsame Statistik bekommt ihre eigene innere Grenze mit eigenem Fallback und schiebt sich nach, sobald sie da ist.
<Suspense fallback={<PageSkeleton />}>
<UserProfile userPromise={userPromise} />
<Suspense fallback={<StatsSkeleton />}>
<Statistics statsPromise={statsPromise} />
</Suspense>
</Suspense>
So beschreibt man ein gestaffeltes Erscheinen als Struktur im Baum, nicht als Logik in den Komponenten. Wo etwas erscheint und in welcher Reihenfolge, liest man an der Verschachtelung der Grenzen ab – an einer Stelle, statt über ein Dutzend isLoading-Abfragen verstreut. Das ist der eigentliche Zugewinn: Die Lade-UI liegt dort, wo man über Layout nachdenkt, und nicht in jeder Komponente noch einmal.
Damit hängt ein zweiter, weniger sichtbarer Vorteil zusammen: Suspense arbeitet gegen Lade-Wasserfälle. Ein Wasserfall entsteht, wenn eine Komponente erst lädt, dann rendert, und ein Kind darin wiederum erst dann mit seinem eigenen Laden beginnt – die Anfragen reihen sich nacheinander auf, obwohl sie voneinander gar nicht abhängen. Genau dieses Muster produziert das klassische useEffect-Laden fast von selbst, weil jede Komponente erst dann fetcht, wenn sie gerendert wird, und Kinder erst rendern, wenn die Eltern fertig geladen haben. Reicht man die Promises dagegen von außen hinein und startet sie früh, laufen die Anfragen parallel, während use sie unter einer gemeinsamen Grenze wieder einsammelt. Die Struktur des Wartens und die Struktur des Ladens fallen nicht mehr zwangsläufig zusammen.
Ein Nachladen, das die alte UI stehen lässt
Ein Detail sei noch angerissen, weil es in der Praxis schnell wichtig wird. Suspense zeigt seinen Fallback nicht nur beim ersten Laden, sondern auch, wenn sich die Daten unter einer Grenze ändern – etwa wenn man von einer Rechnung zur nächsten wechselt und ein neues Promise entsteht. Ohne Vorkehrung springt die Seite dann zurück in den Spinner, obwohl gerade eben noch brauchbare Daten zu sehen waren.
Genau dafür gibt es useTransition und startTransition. Markiert man den Datenwechsel als Übergang, hält React die alte Oberfläche stehen und lädt im Hintergrund nach, statt sofort in den Fallback zu fallen. Man kann währenddessen anzeigen, dass etwas im Gange ist – ausgegraut, mit einem dezenten Hinweis –, ohne den bereits gerenderten Inhalt wegzuwerfen. Das verdient einen eigenen Beitrag; hier soll nur klar sein, dass das harte Zurückspringen in den Fallback kein Zwang ist.
Was bleiben soll
Suspense und use verschieben nicht, wer die Daten besorgt – das bleibt Sache eines Caches. Sie verschieben, wo das Warten beschrieben wird. Statt in jeder Komponente drei Zustände auszuprogrammieren, zieht man Laden und Fehler an zwei Grenzen im Baum und lässt die Komponenten darunter den Erfolgsfall annehmen. Die einzelne Komponente wird dadurch kleiner und ehrlicher: Sie tut so, als seien die Daten da, weil eine Grenze über ihr dafür sorgt, dass sie es sind, wenn die Komponente an die Reihe kommt.
Der Reflex, den ich mir aus diesem Thema mitnehme, ist eine Frage beim Entwurf einer Seite: An welchen Stellen im Baum darf gewartet werden, und was soll dort so lange zu sehen sein? Die Antwort sind ein paar <Suspense>-Grenzen an den richtigen Stellen – und Komponenten, die vom Warten nichts mehr wissen müssen.
Weiterführende Quellen
- Repository introduction-react (Kapitel server-access, react-hooks; Ordner recipes zum Data Fetching): https://github.com/mikebild/introduction-react
- React, „Suspense": https://react.dev/reference/react/Suspense
- React, „use": https://react.dev/reference/react/use
- React, Suspense-fähiges Data Fetching: https://react.dev/reference/react/Suspense#displaying-a-fallback-while-content-is-loading
Kommentare
Kommentar schreiben