React use() und Suspense: Daten-Streaming ohne Wasserfälle
React use() und Suspense: Daten-Streaming ohne Wasserfälle
Im View-Transitions-Artikel erwähnte ich nebenbei, dass eine Suspense-Enthüllung animiert werden kann. Dieser Satz hat eine Annahme eingeschmuggelt: dass die Seite ein Skeleton zum Austauschen hat, was bedeutet, dass die Daten separat von der restlichen Seite ankommen. Das ist keineswegs selbstverständlich. Viele Seiten zeigen immer noch nichts, bis alles bereit ist, und der Grund ist fast immer derselbe – eine Datenlade-Struktur, die Anfragen in eine Warteschlange zwingt.
Diese Warteschlange hat einen Namen, Wasserfall, und hat eine hässliche arithmetische Eigenschaft. Anfrage B kann nicht starten, bis A zurückkommt, C wartet auf B, also ist die Summe nicht die Zeit der langsamsten Anfrage – es ist die Summe aller. Vier 300ms-Anfragen in einer Kette sind 1,2 Sekunden leerer Bildschirm.
Woher Wasserfälle kommen
Die älteste Quelle ist das Laden in useEffect. Eine Komponente rendert, dann feuert der Effekt die Anfrage ab, und wenn die Daten ankommen, rendert eine Kindkomponente und feuert erst dann ihre Anfrage ab. Jede Ebene des Baums fügt eine vollständige Netzwerk-Roundtrip hinzu.
Die zweite Quelle ist leiser und lebt auf dem Server. Wenn man Daten oben in einer Server-Komponente awaitet, blockt die Anfrage das Rendering der Route, bis sie abgeschlossen ist.
Was eine suspendierte Komponente wirklich tut
Bevor das API, zuerst der Mechanismus, weil er jeden Fallstrick in diesem Artikel erklärt.
Suspense ist nicht neu – React hat es seit 2018, für Code-Splitting mit React.lazy. Der Trick, auf dem es aufgebaut wurde, ist ungewöhnlich genug, dass er jahrelang halb geheim war: Eine Komponente, die nicht bereit ist, wirft ein Promise. Kein Fehler, ein Promise. React fängt es wie eine Error Boundary einen Fehler fängt, verwirft die teilweise gerenderte Arbeit, zeigt den nächsten Fallback und abonniert das geworfene Promise. Wenn es sich auflöst, führt React die Komponente von oben erneut aus.
Zwei Konsequenzen ergeben sich daraus:
Rendern muss wiederholbar sein. React verwirft die Arbeit einer suspendierten Komponente und führt sie erneut aus, möglicherweise mehrmals.
Und das Promise muss zwischen diesen Ausführungen stabil sein. Wenn das erneute Ausführen der Komponente ein brandneues Promise erstellt, abonniert React dieses, es löst sich auf, React führt es erneut aus, ein drittes Promise erscheint – eine Komponente, die für immer suspendiert ist. Das ist der häufigste Weg, use() zu brechen.
tsx
"use client";import { use } from "react";export default function Posts({ posts }) {// suspendiert, bis das Promise sich auflöstconst allPosts = use(posts);return (<ul>{allPosts.map((post) => (<li key={post.id}>{post.title}</li>))}</ul>);}
Suspense, die statische Hülle und HTML, das nicht in der Reihenfolge ankommt
<Suspense> markiert die Grenze, auf die eine suspendierte Komponente zurückfällt. In Next.js funktioniert so auch Streaming: React sendet die statische Hülle sofort, und jede Grenze ist ein unabhängiger Streaming-Punkt.
tsx
import { Suspense } from "react";export default function Page() {return (<main><h1>Dashboard</h1> {/* wird sofort gesendet */}<Suspense fallback={<StatsSkeleton />}><Stats /></Suspense><Suspense fallback={<FeedSkeleton />}><Feed /></Suspense></main>);}
Wenn <Stats /> 200ms braucht und <Feed /> 1,5s, bekommt der Benutzer den Header sofort, Statistiken nach 200ms, Feed nach 1,5s.
Wie kommt der spät ankommende HTML in die Mitte eines bereits gestreamten Dokuments? Er wird am Ende des Dokuments angehängt, außer Sichtweite, und React streamt ein winziges Inline-<script> daneben, dessen einzige Aufgabe es ist, diesen Inhalt an die richtige Stelle zu verschieben und den Fallback zu entfernen.
Früh starten, spät lesen
Starte die Anfrage in einer Server-Komponente und awaite sie nicht. Gib das nackte Promise als Prop nach unten und lese es mit use().
tsx
import { Suspense } from "react";import Posts from "@/app/ui/posts";import Comments from "@/app/ui/comments";export default function Page() {// beide Anfragen starten jetzt, parallel; niemand await'et sie hierconst posts = getPosts();const comments = getComments();return (<><Suspense fallback={<div>Beiträge laden...</div>}><Posts posts={posts} /></Suspense><Suspense fallback={<div>Kommentare laden...</div>}><Comments comments={comments} /></Suspense></>);}
Beide Anfragen feuern während eines einzigen Renderns von Page, bevor irgendetwas auf irgendetwas gewartet hat. Die Gesamtzeit ist jetzt die langsamere der beiden, nicht die Summe.
Der Fallstrick, den niemand erwartet: Streaming verbraucht deinen HTTP-Status-Code
Sobald ein Suspense-Fallback rendert und der Stream öffnet, hat der Server bereits 200 OK committed und alle Response-Header gesendet. Von diesem Punkt an kann man den Status-Code nicht ändern. Ein notFound(), das innerhalb einer gestreamten Grenze feuert, kann die Nicht-gefunden-UI rendern, aber keinen echten 404 liefern.
tsx
export default async function PostPage({ params }) {const { slug } = await params;const exists = await checkSlugExists(slug); // schnell, bevor der Stream öffnetif (!exists) notFound(); // echtes 404return (<Suspense fallback={<PostSkeleton />}><PostContent slug={slug} /></Suspense>);}
Was Streaming mit Core Web Vitals macht
- TTFB sinkt auf ungefähr die Zeit zum Rendern des Layouts.
- LCP kann sich verschlechtern, nicht verbessern. Wenn das größte Element innerhalb einer Suspense-Grenze sitzt, kann es nicht rendern, bis diese Grenze sich auflöst. LCP-Elemente in der statischen Hülle halten.
- CLS liegt in deiner Verantwortung. Wenn ein Fallback durch Inhalt unterschiedlicher Größe ersetzt wird, springt alles darunter.
- INP verbessert sich, dank selektiver Hydration. React hydriert Grenzen unabhängig voneinander, wenn sie einstreamen, und priorisiert die Hydration dessen, womit der Benutzer gerade interagiert hat.
Fallstricke und Best Practices
- Niemals das Promise während des Renderns einer Client-Komponente erstellen.
use(fetch(url))innerhalb einer Client-Komponente macht bei jedem Lauf ein neues Promise – das ist eine unendliche Suspension. - Suspense fängt Warten, nicht Versagen. Ein abgelehntes Promise zeigt nicht den Fallback – es wirft. Jede sinnvolle Grenze mit einer Error Boundary paaren.
- Die Grenze wie eine Person bemessen. Eine Grenze um die gesamte Seite stellt „nichts bis alles" wieder her. Eine Grenze um jede Zeile gibt eine Seite, die in vierzig Teilen aufpoppt.
- Bots bekommen den Stream nicht. Crawler werden anders bedient – Next.js wartet auf die Daten und gibt die vollständig gerenderte Seite zurück.
- Ein Promise-Prop überschreitet das Netzwerk. Sein aufgelöster Wert wird in die Nutzlast serialisiert, die an den Browser gesendet wird.
Fazit
Der INP-Artikel und der Scroll-Driven-Animations-Artikel teilten einen Instinkt: Den Hauptthread nicht dazu bringen, Arbeit zu erledigen, die anderswo oder früher stattfinden könnte. Streaming mit use() und Suspense wendet das auf Zeit statt auf Threads an. Statt einer Sequenz bekommt man parallele Streams: Alles startet sofort, und jeder Teil der Seite erscheint, sobald seine eigenen Daten landen.