React use() et Suspense : streaming de données sans cascades
React use() et Suspense : streaming de données sans cascades
Dans l'article sur View Transitions, j'ai mentionné en passant qu'une révélation Suspense peut être animée. Cette phrase a introduit en contrebande une hypothèse : que la page a un skeleton à échanger, ce qui signifie que les données arrivent séparément du reste de la page. Ce n'est pas acquis. Beaucoup de pages ne montrent toujours rien tant que tout n'est pas prêt, et la raison est presque toujours la même — une structure de chargement de données qui force les requêtes dans une file.
Cette file a un nom, cascade, et a une propriété arithmétique laide. Quatre requêtes de 300ms en chaîne représentent 1,2 seconde d'écran vide pour des données qui auraient pu arriver en 300ms.
D'où viennent les cascades
La source la plus ancienne est le chargement dans useEffect. La deuxième source est plus silencieuse et vit sur le serveur. Quand on await des données en haut d'un Server Component, la requête bloque le rendu de la route jusqu'à ce qu'elle se termine.
Ce que fait vraiment un composant suspendu
Un composant qui n'est pas prêt lance une Promise. Pas une erreur, une Promise. React la capture comme une Error Boundary capture une erreur, jette le travail partiellement rendu, affiche le fallback le plus proche, et s'abonne à la Promise lancée. Quand elle se résout, React ré-exécute le composant depuis le début.
Deux conséquences en découlent :
Le rendu doit être répétable. Et la Promise doit être stable entre ces exécutions. Si la ré-exécution du composant crée une toute nouvelle Promise, React s'abonne à celle-là, elle se résout, React ré-exécute — une suspension infinie. C'est la façon la plus courante de casser use().
tsx
"use client";import { use } from "react";export default function Posts({ posts }) {// se suspend jusqu'à ce que la promise se résolveconst allPosts = use(posts);return (<ul>{allPosts.map((post) => (<li key={post.id}>{post.title}</li>))}</ul>);}
Suspense, le shell statique et HTML qui arrive dans le désordre
React envoie le shell statique immédiatement, et chaque limite est un point de streaming indépendant.
tsx
import { Suspense } from "react";export default function Page() {return (<main><h1>Tableau de bord</h1> {/* envoyé immédiatement */}<Suspense fallback={<StatsSkeleton />}><Stats /></Suspense><Suspense fallback={<FeedSkeleton />}><Feed /></Suspense></main>);}
Le morceau tardif est ajouté à la fin du document, hors vue, et React y associe un minuscule <script> inline dont le seul rôle est de déplacer ce contenu au bon endroit.
Démarrer tôt, lire tard
Démarrez la requête dans un Server Component et ne l'awaitez pas. Passez la Promise brute comme prop et lisez-la avec use().
tsx
import { Suspense } from "react";import Posts from "@/app/ui/posts";import Comments from "@/app/ui/comments";export default function Page() {// les deux requêtes démarrent maintenant, en parallèleconst posts = getPosts();const comments = getComments();return (<><Suspense fallback={<div>Chargement des articles...</div>}><Posts posts={posts} /></Suspense><Suspense fallback={<div>Chargement des commentaires...</div>}><Comments comments={comments} /></Suspense></>);}
Le piège que personne n'attend : le streaming dépense votre code de statut HTTP
Une fois qu'un fallback Suspense rend et que le flux s'ouvre, le serveur a déjà commis 200 OK. Un notFound() qui se déclenche à l'intérieur d'une limite streamée peut rendre l'UI "non trouvé", mais ne peut pas donner un vrai 404.
tsx
export default async function PostPage({ params }) {const { slug } = await params;const exists = await checkSlugExists(slug); // rapide, avant l'ouverture du fluxif (!exists) notFound(); // vrai 404return (<Suspense fallback={<PostSkeleton />}><PostContent slug={slug} /></Suspense>);}
Ce que le streaming fait aux Core Web Vitals
- TTFB baisse à peu près au temps de rendu du layout.
- LCP peut s'aggraver, pas s'améliorer. Garder les éléments LCP dans le shell statique.
- CLS est votre responsabilité. Donner aux skeletons les dimensions du contenu qu'ils représentent.
- INP s'améliore, grâce à l'hydration sélective.
Pièges et bonnes pratiques
- Ne jamais créer la Promise pendant le rendu d'un Client Component.
- Suspense attrape l'attente, pas l'échec. Associer chaque limite significative à une Error Boundary.
- Dimensionner la limite comme une personne le ferait.
- Les bots ne reçoivent pas le flux. Next.js attend les données et retourne la page entièrement rendue.
- Une prop Promise traverse le réseau.
Conclusion
Au lieu d'une séquence — requête, attente, rendu, requête, attente, rendu — on obtient des flux parallèles : tout démarre à la fois, et chaque partie de la page apparaît dès que ses propres données arrivent.