React use() i Suspense: streaming danych bez waterfalli
React use() i Suspense: streaming danych bez waterfalli
W artykule o View Transitions wspomniałem mimochodem, że odsłonięcie Suspense można wyanimować – skeleton wyjeżdża, prawdziwa treść wjeżdża. To zdanie przemyciło założenie: że strona ma skeleton do podmiany, czyli że dane docierają osobno od reszty strony. To wcale nie jest oczywiste. Mnóstwo stron nadal nie pokazuje niczego, dopóki nie jest gotowe wszystko, a powód jest prawie zawsze ten sam – struktura pobierania danych, która ustawia zapytania w kolejkę.
Ta kolejka ma nazwę, waterfall, i ma paskudną własność arytmetyczną. Request B nie wystartuje, dopóki nie wróci A, C czeka na B, więc suma to nie czas najwolniejszego zapytania, tylko suma wszystkich. Cztery zapytania po 300 ms w łańcuchu to 1,2 sekundy pustego ekranu na dane, które mogły dotrzeć w 300 ms.
Skąd biorą się waterfalle
Najstarsze źródło to pobieranie w useEffect. Komponent się renderuje, potem efekt odpala request, a gdy dane dotrą, renderuje się dziecko i dopiero wtedy odpala swoje zapytanie. Każdy poziom drzewa dokłada pełną podróż do serwera i z powrotem. Użytkownik ogląda kaskadę spinnerów, po jednym na komponent, każdy z własnym stanem isLoading, który trzeba było napisać i utrzymać w zgodzie z pozostałymi.
Drugie źródło jest cichsze i mieszka na serwerze. Gdy awaitujesz dane na górze Server Componentu, dokumentacja Next.js mówi to wprost: request blokuje renderowanie trasy, dopóki się nie zakończy. Dwa niezależne zapytania awaitowane jedno po drugim to też waterfall, tylko krótszy i taki, który nie rzuca się w oczy w zakładce sieci. A jeśli najwolniejsze zapytanie należy do widgetu z rekomendacjami na samym dole strony, użytkownik gapi się w pustkę, podczas gdy nagłówek – który nie potrzebował żadnych danych – czeka na swoją kolej.
Co naprawdę robi zawieszony komponent
Zanim API, najpierw mechanizm, bo tłumaczy on każdą pułapkę w tym artykule.
Suspense nie jest nowy – React ma go od 2018 roku, do code splittingu przez React.lazy. Sztuczka, na której go zbudowano, jest na tyle nietypowa, że przez lata była na wpół sekretna: komponent, który nie jest gotowy, rzuca Promise. Nie błąd, tylko Promise. React łapie go tak, jak error boundary łapie błąd, wyrzuca częściowo wykonaną pracę, pokazuje najbliższy fallback i subskrybuje rzucony Promise. Gdy ten się rozwiąże, React uruchamia komponent od początku i zatrzymuje to, co wyprodukuje tym razem.
Wynikają z tego dwie konsekwencje i obie mają znaczenie:
Render musi być powtarzalny. React wyrzuci pracę zawieszonego komponentu i uruchomi go ponownie, być może kilka razy. Efekt uboczny siedzący w ciele renderu wykona się za każdym razem.
A Promise musi być stabilny między tymi uruchomieniami. Jeśli ponowne uruchomienie komponentu tworzy zupełnie nowy Promise, React subskrybuje tamten, tamten się rozwiązuje, React uruchamia ponownie, pojawia się trzeci Promise – komponent zawieszony na zawsze, palący procesor, z fallbackiem, który nigdy nie znika. To najczęstszy sposób na zepsucie use() i dlatego reszta tego artykułu tak bardzo przejmuje się tym, gdzie rodzi się Promise.
use() to usankcjonowane API nad tym starym mechanizmem. Czyta zasób w trakcie renderu – najczęściej kontekst albo Promise – a gdy Promise jest w toku, zawiesza komponent. Bez isLoading, bez useEffect; kod czyta się tak, jakby dane po prostu tam były.
tsx
"use client";import { use } from "react";export default function Posts({ posts }) {// zawiesza się, dopóki promise się nie rozwiążeconst allPosts = use(posts);return (<ul>{allPosts.map((post) => (<li key={post.id}>{post.title}</li>))}</ul>);}
Jedna różnica względem zwykłych hooków jest naprawdę użyteczna: use() nie podlega regułom hooków, więc można go wywołać w warunku albo w pętli, po wcześniejszym return. Jest to dozwolone właśnie dzięki modelowi „rzuć i spróbuj ponownie" – nie ma tu slotu hooka, którego kolejności trzeba pilnować.
Suspense, statyczna powłoka i HTML, który dociera nie po kolei
<Suspense> wyznacza granicę, do której spada zawieszony komponent. W Next.js na tym polega też streaming: serwerowy renderer Reacta emituje HTML w kawałkach wyznaczonych przez te granice. Wszystko poza nimi – layout, nawigacja, same fallbacki – to statyczna powłoka (static shell) i wychodzi natychmiast. Każda granica jest niezależnym punktem streamingu; treść w osobnych granicach rozwiązuje się i dociera, nie blokując rodzeństwa.
tsx
import { Suspense } from "react";export default function Page() {return (<main><h1>Dashboard</h1> {/* wysyłane natychmiast */}<Suspense fallback={<StatsSkeleton />}><Stats /></Suspense><Suspense fallback={<FeedSkeleton />}><Feed /></Suspense></main>);}
Jeśli <Stats /> zajmuje 200 ms, a <Feed /> 1,5 s, użytkownik dostaje nagłówek natychmiast, statystyki po 200 ms, feed po 1,5 s – zamiast białego ekranu przez półtorej sekundy.
Nasuwa się pytanie, które warto zadać na głos: jak HTML feedu trafia w środek dokumentu, który serwer już dawno wystrumieniował? Nie da się go tam wstawić – tamte bajty przepadły. Odpowiedź Reacta to mały teatrzyk. Spóźniony kawałek zostaje doklejony na końcu dokumentu, poza polem widzenia, a React streamuje obok niego malutki inline'owy <script>, którego jedynym zadaniem jest przenieść tę treść we właściwe miejsce i usunąć fallback. Dokumentacja Next.js odnotowuje konsekwencję wprost: treść pojawia się dopiero wtedy, gdy ten skrypt się wykona.
Warto to wiedzieć, bo tłumaczy coś, co inaczej wygląda na magię – strumieniowana treść pojawia się zanim React ją zhydratyzuje, bo przeniesienie węzła DOM nie potrzebuje frameworka. I tłumaczy realne ograniczenie: jeśli walczysz o Largest Contentful Paint liczony w setkach milisekund, element za granicą Suspense płaci za podróż przez ten skrypt, a element w statycznej powłoce nie.
Startuj wcześnie, czytaj późno
Teraz wzorzec, który to spina. W Client Componencie, który potrzebuje danych, kusi, żeby pobrać je właśnie w nim. Dokumentacja zaleca odwrotność: uruchom request w Server Componencie i go nie awaituj. Przekaż goły Promise w propsie i odczytaj przez use().
tsx
import { Suspense } from "react";import Posts from "@/app/ui/posts";import Comments from "@/app/ui/comments";export default function Page() {// oba requesty startują teraz, równolegle; nikt ich tu nie awaitujeconst posts = getPosts();const comments = getComments();return (<><Suspense fallback={<div>Ładowanie wpisów...</div>}><Posts posts={posts} /></Suspense><Suspense fallback={<div>Ładowanie komentarzy...</div>}><Comments comments={comments} /></Suspense></>);}
Spójrz na moment startu. Oba zapytania odpalają się podczas jednego renderu Page, zanim cokolwiek na cokolwiek czekało. Łączny czas to teraz wolniejsze z dwóch, a nie suma. Waterfall znika nie dlatego, że jakaś biblioteka pogrupowała zapytania, tylko przez to, gdzie powstał Promise – wcześnie, wysoko i niezależnie od renderowania komponentów, które go konsumują. To także powód, dla którego Promise jest stabilny: powstaje raz, na serwerze, a nie przy każdym ponownym uruchomieniu komponentu, który go czyta.
Nie każdy waterfall jest jednak błędem. Jeśli drugie zapytanie naprawdę potrzebuje wartości z pierwszego – playlisty artysty, którego trzeba najpierw znaleźć po nazwie – kolejność jest realna i nic jej nie zrównolegli. Kontrolujesz to, na co użytkownik w tym czasie patrzy. Umieść zależną część za własną granicą, a nazwa pojawi się, gdy tylko wróci pierwsze zapytanie:
tsx
export default async function Page({ params }) {const { username } = await params;const artist = await getArtist(username);return (<><h1>{artist.name}</h1><Suspense fallback={<div>Ładowanie playlist...</div>}><Playlists artistID={artist.id} /></Suspense></>);}
Łańcuch zostaje, skrócony do jednego nieuniknionego ogniwa, i przestaje blokować tę część strony, która była gotowa od początku.
Pułapka, której nikt się nie spodziewa: streaming wydaje twój kod statusu HTTP
Oto ograniczenie, które łapie zespoły na produkcji, a nie na developmencie, i wynika z jednego faktu o HTTP: nagłówki wychodzą przed ciałem odpowiedzi.
W chwili, gdy renderuje się fallback Suspense i otwiera się strumień, serwer już zadeklarował 200 OK i wysłał wszystkie nagłówki odpowiedzi. Od tego momentu nie zmienisz kodu statusu, nie dodasz nagłówka i nie przekierujesz na poziomie protokołu. Więc notFound() odpalone wewnątrz strumieniowanej granicy wyrenderuje UI „nie znaleziono", ale nie da ci prawdziwego 404 – 200 wyjechało stąd sekundę temu.
Dla stron, które crawler albo system monitoringu ocenia po kodzie statusu, reguła jest prosta: zrób tanie sprawdzenie istnienia zanim cokolwiek zdąży się zawiesić.
tsx
export default async function PostPage({ params }) {const { slug } = await params;const exists = await checkSlugExists(slug); // szybkie, przed otwarciem strumieniaif (!exists) notFound(); // prawdziwe 404return (<Suspense fallback={<PostSkeleton />}><PostContent slug={slug} /> {/* wolna część leci strumieniem */}</Suspense>);}
Jedno szybkie zapytanie awaitowane na wstępie to świadomy, malutki waterfall wymieniony na poprawny kod statusu. To rodzaj wymiany, którą lepiej zrobić celowo, niż odkryć później w raporcie z crawlowania.
Co streaming robi z Core Web Vitals
Od artykułu o INP ten blog ma przewodni wątek, a streaming dotyka go w całości:
- TTFB spada mniej więcej do czasu wyrenderowania layoutu, zamiast czasu najwolniejszego zapytania. Powłoka wychodzi wcześnie, co oznacza też, że znaczniki
<link>i<script>w pierwszym kawałku pozwalają przeglądarce zacząć ściągać CSS, JS i fonty, gdy serwer jeszcze pracuje. - LCP może się pogorszyć, nie poprawić. Jeśli twój największy element – hero, główny nagłówek – siedzi wewnątrz granicy Suspense, nie namaluje się, dopóki ta granica się nie rozwiąże i nie wykona się jej inline'owy skrypt. Trzymaj elementy LCP w statycznej powłoce, poza granicami.
- CLS jest na twojej głowie. Gdy fallback zostaje podmieniony na treść o innym rozmiarze, wszystko poniżej podskakuje. Nadaj skeletonom wymiary rzeczy, którą zastępują, albo zarezerwuj miejsce przez
min-height– albo wyanimuj podmianę przez View Transition, jak w poprzednim artykule. - INP się poprawia dzięki selective hydration. React hydratyzuje granice niezależnie, w miarę jak dopływają strumieniem, i priorytetyzuje hydratację tego, z czym użytkownik właśnie wszedł w interakcję. Zamiast jednego długiego zadania hydratacji blokującego główny wątek – dokładnie tego długiego zadania, o którym był artykuł o INP – dostajesz kilka mniejszych, uszeregowanych według tego, czego użytkownik faktycznie dotknął.
Pułapki i dobre praktyki
- Nigdy nie twórz Promise w renderze Client Componentu.
use(fetch(url))wewnątrz Client Componentu tworzy nowy Promise przy każdym uruchomieniu, a zgodnie z opisanym wyżej mechanizmem „rzuć i spróbuj ponownie" to zawieszenie bez końca. Promise musi pochodzić ze stabilnego miejsca: z serwera, z cache'u albo ze składnicy na poziomie modułu. - Suspense łapie czekanie, nie porażkę. Odrzucony Promise nie pokazuje fallbacku – propaguje się jako błąd. Sparuj każdą sensowną granicę z Error Boundary, bo inaczej jedno nieudane zapytanie kładzie drzewo zamiast jednej karty.
- Zakładaj, że React może użyć każdej granicy, którą zadeklarujesz. Dokumentacja Next.js mówi to bez ogródek: przy wolnej sieci albo zajętym procesorze współbieżne renderowanie może spaść do granicy nawet tam, gdzie się tego nie spodziewałeś. Dodanie granicy oznacza zgodę na to, że jej fallback się pokaże.
- Wymiaruj granicę tak, jak zrobiłby to człowiek. Jedna granica wokół całej strony przywraca „nic, dopóki nie ma wszystkiego". Granica wokół każdej linijki daje stronę wyskakującą czterdziestoma kawałkami. Obejmuj fragmenty, które użytkownik nazwałby jedną rzeczą.
- Boty nie dostają strumienia. Crawlery są obsługiwane inaczej – Next.js czeka na dane i zwraca w pełni wyrenderowaną stronę. Dobre dla SEO, ale oznacza, że wolne zapytanie uderza w TTFB crawlera tak, jak nigdy nie uderza w prawdziwego użytkownika.
- Podglądaj stany ładowania, zamiast je sobie wyobrażać. React DevTools potrafi na żądanie wprowadzić granicę w stan fallbacku, co jest jedynym rozsądnym sposobem na sprawdzenie skeletonu, który przy szybkim łączu widziałbyś przez 80 ms – i jedynym sposobem, żeby zauważyć, że jest o 40px niższy niż treść, którą zastępuje.
- Promise w propsie przekracza sieć. Jego rozwiązana wartość jest serializowana do payloadu wysyłanego do przeglądarki. Nie wręczaj klientowi Promise'a na więcej danych, niż klient ma prawo zobaczyć.
Podsumowanie
Artykuł o INP i artykuł o scroll-driven animations łączył jeden instynkt: nie każ głównemu wątkowi robić pracy, która mogłaby wydarzyć się gdzie indziej albo wcześniej. Streaming przez use() i Suspense stosuje go do czasu zamiast do wątków. Zamiast sekwencji – zapytanie, czekanie, render, zapytanie, czekanie, render – dostajesz równoległe strumienie: wszystko startuje naraz, a każda część strony pojawia się w chwili, gdy dotrą jej własne dane. Koszt w kodzie jest bliski zeru; przestawić trzeba nawyk. Twórz Promise'y wcześnie i wysoko, czytaj je późno i nisko, rysuj granice tam, gdzie narysowałby je użytkownik, i pamiętaj, że w chwili otwarcia strumienia kod statusu jest już wydany.