Cours Next.js · Module 5: Stratégies de rendu

Streaming de données et React Suspense

19 min de lecture
Dans cette leçon10

Dans le développement d'applications web modernes, la performance et la réactivité de l'interface utilisateur sont des facteurs clés de l'expérience utilisateur. Next.js, en tant que framework frontend, introduit des techniques avancées qui aident à obtenir une expérience fluide même lors d'opérations de récupération de données complexes. Dans ce module, nous verrons comment l'App Router de Next.js utilise le streaming de données et React Suspense pour créer des applications plus réactives.

Les limites du rendu traditionnel

Avant d'aborder le streaming de données, il est utile de comprendre les limites des approches de rendu traditionnelles :

  1. Rendu bloquant - de façon classique, toute la page doit attendre que toutes les données soient récupérées avant que quoi que ce soit ne s'affiche à l'utilisateur
  2. Récupération en cascade (waterfall) - récupération séquentielle des données où chaque requête doit attendre la fin de la précédente
  3. Tout ou rien - la page entière est rendue comme un tout indivisible

Ces limites entraînent un Time To First Byte (TTFB) plus lent et peuvent frustrer l'utilisateur, surtout sur des connexions lentes ou avec des requêtes de base de données complexes.

Streaming de données - une nouvelle approche

Le streaming de données, introduit dans l'App Router de Next.js, permet d'envoyer progressivement des fragments d'UI du serveur vers le client au fur et à mesure qu'ils sont prêts, au lieu d'attendre la fin de toutes les opérations de récupération de données.

Les principaux avantages de cette approche sont :

  1. TTFB plus rapide (Time To First Byte) - l'utilisateur voit le premier contenu plus tôt
  2. Rendu progressif - l'UI est rendue petit à petit, donnant une impression de réactivité accrue
  3. Éviter le blocage - les composants plus lents ne bloquent pas le rendu des composants plus rapides

React Suspense - le fondement du streaming dans React

Suspense est une fonctionnalité de React introduite dans la version 16.6 qui permet de « suspendre » le rendu d'un composant jusqu'à ce qu'une condition soit remplie (par ex. le chargement des données). L'App Router de Next.js exploite pleinement cette fonctionnalité pour mettre en œuvre le streaming de données.

L'idée fondamentale de Suspense est :

  1. Permettre aux composants de « mettre en pause » leur rendu en attendant les données
  2. Afficher un composant de repli (fallback) pendant le chargement
  3. Remplacer le fallback par le composant réel une fois les données prêtes

Implémenter le streaming de données dans l'App Router de Next.js

1. Exemple de base d'utilisation de React Suspense

Commençons par un exemple de base montrant comment utiliser Suspense dans Next.js :

1// app/dashboard/page.tsx
2import { Suspense } from 'react';
3import { UserProfile } from './user-profile';
4import { RecentOrders } from './recent-orders';
5import { Analytics } from './analytics';
6import { Loading } from '@/components/loading';
7
8export default function DashboardPage() {
9  return (
10    <div className="dashboard-container">
11      <h1>Tableau de bord de l'utilisateur</h1>
12
13      {/* En-tête rendu immédiatement */}
14      <div className="dashboard-header">
15        <Suspense fallback={<Loading />}>
16          <UserProfile />
17        </Suspense>
18      </div>
19
20      <div className="dashboard-grid">
21        {/* Section des commandes avec son propre état de chargement */}
22        <div className="orders-section">
23          <Suspense fallback={<Loading type="orders" />}>
24            <RecentOrders />
25          </Suspense>
26        </div>
27
28        {/* Section analytique avec son propre état de chargement */}
29        <div className="analytics-section">
30          <Suspense fallback={<Loading type="analytics" />}>
31            <Analytics />
32          </Suspense>
33        </div>
34      </div>
35    </div>
36  );
37}

Dans cet exemple :

  1. La page du tableau de bord est divisée en sections indépendantes
  2. Chaque section est enveloppée dans un composant Suspense avec un fallback approprié
  3. Les sections sont rendues indépendamment dès que leurs données sont prêtes

Les composants UserProfile, RecentOrders et Analytics peuvent récupérer des données de façon asynchrone :

1// app/dashboard/user-profile.tsx
2async function getUserData() {
3  // Récupération des données utilisateur - peut prendre 500 ms
4  const res = await fetch('/api/user');
5  return res.json();
6}
7
8export async function UserProfile() {
9  const user = await getUserData();
10
11  return (
12    <div className="user-profile">
13      <img src={user.avatar} alt={user.name} />
14      <h2>{user.name}</h2>
15      <p>{user.email}</p>
16    </div>
17  );
18}
1// app/dashboard/recent-orders.tsx
2async function getRecentOrders() {
3  // Récupération des commandes récentes - peut prendre 2 s
4  const res = await fetch('/api/orders/recent');
5  return res.json();
6}
7
8export async function RecentOrders() {
9  const orders = await getRecentOrders();
10
11  return (
12    <div>
13      <h3>Commandes récentes</h3>
14      <table>
15        <thead>
16          <tr>
17            <th>ID</th>
18            <th>Produit</th>
19            <th>Prix</th>
20            <th>Statut</th>
21          </tr>
22        </thead>
23        <tbody>
24          {orders.map(order => (
25            <tr key={order.id}>
26              <td>{order.id}</td>
27              <td>{order.product}</td>
28              <td>${order.price}</td>
29              <td>{order.status}</td>
30            </tr>
31          ))}
32        </tbody>
33      </table>
34    </div>
35  );
36}

2. Composants Suspense imbriqués

On peut créer des structures plus complexes avec des composants Suspense imbriqués :

1// app/products/[category]/page.tsx
2import { Suspense } from 'react';
3import { CategoryHeader } from './category-header';
4import { ProductGrid } from './product-grid';
5import { ProductFilter } from './product-filter';
6import { RelatedCategories } from './related-categories';
7import { SimilarProducts } from './similar-products';
8import { Loading } from '@/components/loading';
9
10export default async function CategoryPage({ params }) {
11  return (
12    <div className="category-page">
13      {/* Composants Suspense imbriqués */}
14      <Suspense fallback={<Loading height="200px" />}>
15        <CategoryHeader category={(await params).category} />
16
17        <div className="product-layout">
18          <aside className="filters">
19            <Suspense fallback={<Loading type="filters" />}>
20              <ProductFilter category={(await params).category} />
21            </Suspense>
22          </aside>
23
24          <main className="products">
25            <Suspense fallback={<Loading type="products" count={12} />}>
26              <ProductGrid category={(await params).category} />
27            </Suspense>
28          </main>
29        </div>
30
31        <div className="recommendations">
32          <Suspense fallback={<Loading type="categories" />}>
33            <RelatedCategories category={(await params).category} />
34          </Suspense>
35
36          <Suspense fallback={<Loading type="products" />}>
37            <SimilarProducts category={(await params).category} />
38          </Suspense>
39        </div>
40      </Suspense>
41    </div>
42  );
43}

Dans cet exemple :

  1. Le Suspense extérieur enveloppe toute la vue de la catégorie
  2. Les composants Suspense intérieurs découpent la page en sections plus petites se chargeant indépendamment
  3. Chaque section a son propre état de chargement adapté au type de contenu

3. Streaming avec des Client Components

On peut combiner le streaming avec des composants client, ce qui permet de créer des UI interactives au chargement fluide :

1// app/products/[id]/page.tsx
2import { Suspense } from 'react';
3import { ProductDetails } from './product-details';
4import { ProductReviews } from './product-reviews';
5import AddToCartButton from './add-to-cart-button'; // Client component
6import ProductImageGallery from './product-image-gallery'; // Client component
7import { Loading } from '@/components/loading';
8
9export default async function ProductPage({ params }) {
10  return (
11    <div className="product-page">
12      <div className="product-main">
13        <Suspense fallback={<Loading type="image-gallery" />}>
14          <ProductImageWrapper productId={(await params).id} />
15        </Suspense>
16
17        <div className="product-info">
18          <Suspense fallback={<Loading type="product-details" />}>
19            <ProductDetailsWrapper productId={(await params).id} />
20          </Suspense>
21
22          {/* Composant client pour l'interactivité */}
23          <AddToCartButton productId={(await params).id} />
24        </div>
25      </div>
26
27      <div className="product-reviews">
28        <Suspense fallback={<Loading type="reviews" />}>
29          <ProductReviews productId={(await params).id} />
30        </Suspense>
31      </div>
32    </div>
33  );
34}
35
36// Composants wrapper nécessitant des props
37async function ProductImageWrapper({ productId }) {
38  const product = await getProduct(productId);
39  return <ProductImageGallery images={product.images} />;
40}
41
42async function ProductDetailsWrapper({ productId }) {
43  const product = await getProduct(productId);
44  return <ProductDetails product={product} />;
45}
46
47async function getProduct(id) {
48  const res = await fetch(`/api/products/${id}`);
49  return res.json();
50}

Dans cet exemple :

  1. ProductImageGallery et AddToCartButton sont des composants client ('use client')
  2. On enveloppe les composants client dans des composants serveur asynchrones pour leur transmettre des données
  3. Le composant AddToCartButton est interactif dès le chargement, même si d'autres parties de la page sont encore en cours de chargement

Techniques avancées de streaming de données

1. Récupération de données en parallèle

L'une des bonnes pratiques du streaming de données consiste à paralléliser les requêtes pour éviter la récupération en cascade :

1// app/dashboard/page.tsx
2import { Suspense } from 'react';
3import { UserProfile } from './user-profile';
4import { RecentOrders } from './recent-orders';
5import { Analytics } from './analytics';
6import { Loading } from '@/components/loading';
7
8// Pré-initialisation des requêtes - éviter la cascade de données
9export function generateMetadata() {
10  // On initialise la récupération des données en avance pour qu'elles soient prêtes
11  // quand les composants en ont besoin
12  preloadUserData();
13  preloadRecentOrders();
14  preloadAnalytics();
15
16  return {
17    title: 'Dashboard - My Application',
18  };
19}
20
21// Fonctions de pré-initialisation
22function preloadUserData() {
23  void getUserData(); // void ignore la Promise
24}
25
26function preloadRecentOrders() {
27  void getRecentOrders(); // void ignore la Promise
28}
29
30function preloadAnalytics() {
31  void getAnalyticsData(); // void ignore la Promise
32}
33
34// Fonctions de récupération de données avec cache
35async function getUserData() {
36  const res = await fetch('/api/user', { cache: 'force-cache' });
37  return res.json();
38}
39
40async function getRecentOrders() {
41  const res = await fetch('/api/orders/recent', { cache: 'force-cache' });
42  return res.json();
43}
44
45async function getAnalyticsData() {
46  const res = await fetch('/api/analytics', { cache: 'force-cache' });
47  return res.json();
48}
49
50export default function DashboardPage() {
51  return (
52    <div className="dashboard-container">
53      {/* ... structure de la page avec les composants Suspense, comme précédemment ... */}
54    </div>
55  );
56}

Dans cette approche :

  1. On initialise la récupération des données dès la fonction generateMetadata, avant que les composants ne la demandent
  2. On utilise void pour ignorer la Promise, puisqu'on n'a pas besoin de son résultat dans cette fonction
  3. Dans les composants, on utilise les mêmes fonctions de récupération qui exploitent le cache

2. Suspense en cascade - prioriser le contenu

On peut hiérarchiser stratégiquement le chargement du contenu afin que les éléments les plus importants s'affichent en premier :

1// app/article/[slug]/page.tsx
2import { Suspense } from 'react';
3import { ArticleHeader } from './article-header';
4import { ArticleContent } from './article-content';
5import { ArticleComments } from './article-comments';
6import { RelatedArticles } from './related-articles';
7import { AuthorBio } from './author-bio';
8import { SocialSharing } from './social-sharing';
9import { Loading } from '@/components/loading';
10
11export default async function ArticlePage({ params }) {
12  return (
13    <article className="article-container">
14      {/* Priorité 1 : en-tête de l'article - se charge en premier */}
15      <Suspense fallback={<Loading type="header" />}>
16        <ArticleHeader slug={(await params).slug} />
17      </Suspense>
18
19      <div className="article-grid">
20        <div className="article-main">
21          {/* Priorité 2 : contenu de l'article */}
22          <Suspense fallback={<Loading type="content" />}>
23            <ArticleContent slug={(await params).slug} />
24
25            <div className="article-footer">
26              {/* Priorité 3 : auteur et partage - se chargent après le contenu */}
27              <Suspense fallback={<Loading type="author" />}>
28                <AuthorBio slug={(await params).slug} />
29              </Suspense>
30
31              <SocialSharing url={`/article/${(await params).slug}`} />
32            </div>
33          </Suspense>
34        </div>
35
36        <aside className="article-sidebar">
37          {/* Priorité 4 : articles liés - se chargent à la fin */}
38          <Suspense fallback={<Loading type="related" />}>
39            <RelatedArticles slug={(await params).slug} />
40          </Suspense>
41        </aside>
42      </div>
43
44      {/* Priorité 5 : commentaires - se chargent en dernier */}
45      <Suspense fallback={<Loading type="comments" />}>
46        <ArticleComments slug={(await params).slug} />
47      </Suspense>
48    </article>
49  );
50}

Dans cet exemple :

  1. On établit une hiérarchie de priorité claire pour le contenu de la page
  2. Les éléments les plus critiques (en-tête, contenu) sont diffusés en premier
  3. Les éléments moins critiques (commentaires, articles liés) sont diffusés ensuite
  4. En imbriquant les composants Suspense, on crée un flux de chargement naturel du contenu

3. Diffuser des composants avec des données dépendantes

Parfois, on a besoin qu'un composant attende les données d'un autre composant. On peut y parvenir à l'aide d'un contexte et de ressources partagées :

1// app/shop/[category]/[subcategory]/page.tsx
2import { Suspense } from 'react';
3import { CategoryProvider } from './category-context';
4import { CategoryInfo } from './category-info';
5import { SubcategoryProducts } from './subcategory-products';
6import { Loading } from '@/components/loading';
7
8export default async function SubcategoryPage({ params }) {
9  const { category, subcategory } = await params;
10
11  return (
12    <div className="shop-page">
13      <CategoryProvider category={category}>
14        <Suspense fallback={<Loading type="category" />}>
15          <CategoryInfo />
16
17          {/* Ce composant a besoin des données de CategoryInfo */}
18          <Suspense fallback={<Loading type="products" />}>
19            <SubcategoryProducts subcategory={subcategory} />
20          </Suspense>
21        </Suspense>
22      </CategoryProvider>
23    </div>
24  );
25}
1// app/shop/[category]/[subcategory]/category-context.tsx
2'use client';
3
4import { createContext, useContext, useState, useEffect } from 'react';
5
6const CategoryContext = createContext(null);
7
8export function CategoryProvider({ children, category }) {
9  const [categoryData, setCategoryData] = useState(null);
10  const [loading, setLoading] = useState(true);
11
12  useEffect(() => {
13    async function loadCategoryData() {
14      try {
15        const res = await fetch(`/api/categories/${category}`);
16        const data = await res.json();
17        setCategoryData(data);
18      } catch (error) {
19        console.error('Error loading category data:', error);
20      } finally {
21        setLoading(false);
22      }
23    }
24
25    loadCategoryData();
26  }, [category]);
27
28  return (
29    <CategoryContext.Provider value={{ categoryData, loading }}>
30      {children}
31    </CategoryContext.Provider>
32  );
33}
34
35export function useCategoryContext() {
36  const context = useContext(CategoryContext);
37  if (!context) {
38    throw new Error('useCategoryContext must be used within a CategoryProvider');
39  }
40  return context;
41}

In the components receiving data:

1// app/shop/[category]/[subcategory]/category-info.tsx
2'use client';
3
4import { useCategoryContext } from './category-context';
5
6export function CategoryInfo() {
7  const { categoryData, loading } = useCategoryContext();
8
9  if (loading) {
10    return <div>Chargement des informations de la catégorie...</div>;
11  }
12
13  return (
14    <div className="category-info">
15      <h1>{categoryData.name}</h1>
16      <p>{categoryData.description}</p>
17      <div className="category-stats">
18        <span>{categoryData.productCount} produits</span>
19        <span>{categoryData.brandsCount} marques</span>
20      </div>
21    </div>
22  );
23}
1// app/shop/[category]/[subcategory]/subcategory-products.tsx
2'use client';
3
4import { useCategoryContext } from './category-context';
5import { useEffect, useState } from 'react';
6
7export function SubcategoryProducts({ subcategory }) {
8  const { categoryData } = useCategoryContext();
9  const [products, setProducts] = useState([]);
10  const [loading, setLoading] = useState(true);
11
12  useEffect(() => {
13    // Attend que categoryData soit disponible
14    if (!categoryData) return;
15
16    async function loadProducts() {
17      try {
18        // Utilise categoryData pour récupérer les produits
19        const res = await fetch(
20          `/api/products?category=${categoryData.id}&subcategory=${subcategory}`
21        );
22        const data = await res.json();
23        setProducts(data);
24      } catch (error) {
25        console.error('Error loading products:', error);
26      } finally {
27        setLoading(false);
28      }
29    }
30
31    loadProducts();
32  }, [categoryData, subcategory]);
33
34  if (loading) {
35    return <div>Chargement des produits...</div>;
36  }
37
38  return (
39    <div className="products-grid">
40      {products.map(product => (
41        <div key={product.id} className="product-card">
42          <img src={product.image} alt={product.name} />
43          <h3>{product.name}</h3>
44          <p>${product.price}</p>
45        </div>
46      ))}
47    </div>
48  );
49}

Server Components vs Client Components dans le contexte du streaming

Lors de la mise en œuvre du streaming de données dans l'App Router de Next.js, il est important de comprendre les différences entre composants serveur et composants client :

Server Components

Les composants serveur sont idéaux pour le streaming car :

  1. Ils peuvent récupérer des données de façon asynchrone directement depuis des bases de données ou des API
  2. Ils sont rendus sur le serveur et envoyés au client sous forme de HTML
  3. Ils peuvent être enveloppés dans Suspense pour gérer l'état de chargement
1// app/products/product-list.tsx (Server Component)
2async function getProducts() {
3  // Accès direct à la base de données sur le serveur
4  const products = await db.products.findMany();
5  return products;
6}
7
8export async function ProductList() {
9  const products = await getProducts();
10
11  return (
12    <div className="product-list">
13      {products.map(product => (
14        <div key={product.id} className="product-item">
15          <h3>{product.name}</h3>
16          <p>${product.price}</p>
17        </div>
18      ))}
19    </div>
20  );
21}

Client Components

Les composants client sont nécessaires à l'interactivité mais se comportent différemment dans le contexte du streaming :

  1. Ils ne peuvent pas être directement asynchrones
  2. Ils utilisent les hooks React (useState, useEffect) pour récupérer des données
  3. Ils sont rendus sur le client et nécessitent l'envoi de JS au navigateur
1// app/products/filter-panel.tsx (Client Component)
2'use client';
3
4import { useState, useEffect } from 'react';
5
6export function FilterPanel({ onFilterChange }) {
7  const [categories, setCategories] = useState([]);
8  const [loading, setLoading] = useState(true);
9  const [selectedCategory, setSelectedCategory] = useState(null);
10
11  useEffect(() => {
12    async function loadCategories() {
13      try {
14        const res = await fetch('/api/categories');
15        const data = await res.json();
16        setCategories(data);
17      } catch (error) {
18        console.error('Error loading categories:', error);
19      } finally {
20        setLoading(false);
21      }
22    }
23
24    loadCategories();
25  }, []);
26
27  function handleCategoryChange(categoryId) {
28    setSelectedCategory(categoryId);
29    onFilterChange({ category: categoryId });
30  }
31
32  if (loading) return <div>Chargement des filtres...</div>;
33
34  return (
35    <div className="filter-panel">
36      <h3>Catégories</h3>
37      <ul>
38        {categories.map(category => (
39          <li key={category.id}>
40            <button
41              onClick={() => handleCategoryChange(category.id)}
42              className={selectedCategory === category.id ? 'active' : ''}
43            >
44              {category.name}
45            </button>
46          </li>
47        ))}
48      </ul>
49    </div>
50  );
51}

Combiner Server Components et Client Components

La bonne pratique consiste à utiliser des Server Components pour récupérer les données et préparer le contenu, et des Client Components pour l'interactivité :

1// app/products/page.tsx
2import { Suspense } from 'react';
3import { ProductList } from './product-list'; // Server Component
4import FilterPanel from './filter-panel'; // Client Component
5import { Loading } from '@/components/loading';
6
7export default function ProductsPage() {
8  return (
9    <div className="products-page">
10      <div className="sidebar">
11        <FilterPanel onFilterChange={/* gestion côté client */} />
12      </div>
13
14      <div className="main-content">
15        <Suspense fallback={<Loading type="products" />}>
16          <ProductList />
17        </Suspense>
18      </div>
19    </div>
20  );
21}

Gestion des erreurs lors du streaming de données

Pendant le streaming de données, une bonne gestion des erreurs est essentielle. L'App Router de Next.js fournit un composant Error Boundary pour gérer les erreurs dans les composants diffusés :

1// app/dashboard/error.tsx
2'use client';
3
4import { useEffect } from 'react';
5
6export default function Error({
7  error,
8  reset
9}: {
10  error: Error & { digest?: string };
11  reset: () => void;
12}) {
13  useEffect(() => {
14    // Journalise l'erreur dans un service de monitoring
15    console.error('Dashboard error:', error);
16  }, [error]);
17
18  return (
19    <div className="error-container">
20      <h2>Une erreur est survenue !</h2>
21      <p>{error.message || 'An unexpected error occurred.'}</p>
22      <button
23        onClick={() => reset()}
24        className="button"
25      >
26        Réessayer
27      </button>
28    </div>
29  );
30}

On peut aussi créer une Error Boundary pour des sections spécifiques :

1// app/dashboard/analytics-error.tsx
2'use client';
3
4export default function AnalyticsError({ error, reset }) {
5  return (
6    <div className="section-error">
7      <h3>Erreur de chargement des analyses</h3>
8      <p>Impossible de charger les données analytiques : {error.message}</p>
9      <button onClick={reset}>Réessayer</button>
10    </div>
11  );
12}

Puis on l'utilise pour des sections spécifiques :

1// app/dashboard/page.tsx
2import { Suspense } from 'react';
3import { ErrorBoundary } from 'react-error-boundary';
4import { UserProfile } from './user-profile';
5import { RecentOrders } from './recent-orders';
6import { Analytics } from './analytics';
7import AnalyticsError from './analytics-error';
8import { Loading } from '@/components/loading';
9
10export default function DashboardPage() {
11  return (
12    <div className="dashboard-container">
13      {/* ... autres sections ... */}
14
15      <div className="analytics-section">
16        <ErrorBoundary FallbackComponent={AnalyticsError}>
17          <Suspense fallback={<Loading type="analytics" />}>
18            <Analytics />
19          </Suspense>
20        </ErrorBoundary>
21      </div>
22    </div>
23  );
24}

Optimiser les performances du streaming

Une bonne implémentation du streaming de données peut améliorer considérablement les performances de l'application. Voici quelques techniques d'optimisation supplémentaires :

1. Éviter le scintillement de l'interface

1// app/layout.tsx
2export default function RootLayout({ children }) {
3  return (
4    <html lang="en">
5      <body>
6        {/* Définition d'un délai minimal pour Suspense */}
7        <script dangerouslySetInnerHTML={{ __html: `
8          window.SuspenseMinDelay = 300; // ms
9        ` }} />
10        {children}
11      </body>
12    </html>
13  );
14}

On peut aussi créer un composant personnalisé pour retarder l'état de chargement :

1// components/delayed-suspense.tsx
2'use client';
3
4import { Suspense, useState, useEffect } from 'react';
5
6export function DelayedSuspense({
7  children,
8  fallback,
9  delay = 300
10}) {
11  const [showFallback, setShowFallback] = useState(false);
12
13  useEffect(() => {
14    const timer = setTimeout(() => {
15      setShowFallback(true);
16    }, delay);
17
18    return () => clearTimeout(timer);
19  }, [delay]);
20
21  return (
22    <Suspense fallback={showFallback ? fallback : null}>
23      {children}
24    </Suspense>
25  );
26}

2. Prioriser les données critiques

1// app/products/[id]/page.tsx
2import { Suspense } from 'react';
3import { ProductBasicInfo } from './product-basic-info'; // Critical data
4import { ProductDetails } from './product-details'; // Important data
5import { ProductReviews } from './product-reviews'; // Less critical data
6import { RelatedProducts } from './related-products'; // Least critical data
7import { Loading } from '@/components/loading';
8
9export default async function ProductPage({ params }) {
10  // Pré-initialise les données critiques
11  preloadProductBasicInfo((await params).id);
12
13  return (
14    <div className="product-page">
15      {/* Rendu immédiat des informations critiques */}
16      <div className="product-hero">
17        <Suspense fallback={<Loading priority="high" />}>
18          <ProductBasicInfo productId={(await params).id} />
19        </Suspense>
20      </div>
21
22      {/* Sections moins critiques chargées plus tard */}
23      <div className="product-content">
24        <Suspense fallback={<Loading priority="medium" />}>
25          <ProductDetails productId={(await params).id} />
26        </Suspense>
27      </div>
28
29      <div className="product-secondary">
30        <Suspense fallback={<Loading priority="low" />}>
31          <ProductReviews productId={(await params).id} />
32        </Suspense>
33      </div>
34
35      <div className="product-related">
36        <Suspense fallback={<Loading priority="lowest" />}>
37          <RelatedProducts productId={(await params).id} />
38        </Suspense>
39      </div>
40    </div>
41  );
42}
43
44// Pré-initialise les données critiques
45function preloadProductBasicInfo(id) {
46  void getProductBasicInfo(id);
47}
48
49async function getProductBasicInfo(id) {
50  const res = await fetch(`/api/products/${id}/basic`, {
51    priority: 'high' // Définition de la priorité de l'API fetch
52  });
53  return res.json();
54}

3. Limiter les décalages de mise en page (CLS)

1// components/loading.tsx
2export function Loading({ type, priority = 'medium', height, width }) {
3  // Détermine les dimensions du skeleton selon le type de contenu
4  const dimensions = getDimensionsForType(type, { height, width });
5
6  return (
7    <div
8      className={`skeleton-loader ${type} priority-${priority}`}
9      style={{
10        height: dimensions.height,
11        width: dimensions.width,
12        // Des dimensions prédéfinies empêchent les décalages de mise en page
13      }}
14    >
15      <div className="skeleton-animation" />
16    </div>
17  );
18}
19
20function getDimensionsForType(type, userDimensions) {
21  // Dimensions par défaut pour différents types de contenu
22  const defaults = {
23    'header': { height: '100px', width: '100%' },
24    'products': { height: '300px', width: '100%' },
25    'sidebar': { height: '400px', width: '250px' },
26    // ... autres types
27  };
28
29  return {
30    height: userDimensions.height || defaults[type]?.height || '200px',
31    width: userDimensions.width || defaults[type]?.width || '100%'
32  };
33}

Cas d'usage typiques du streaming de données

Le streaming de données est particulièrement utile dans les scénarios suivants :

1. Applications e-commerce

  • Chargement rapide des informations produit de base
  • Chargement asynchrone des avis et des recommandations
  • Filtres et recherche interactifs pendant le chargement des produits

2. Tableaux de bord analytiques

  • Chargement immédiat des métriques critiques
  • Chargement progressif des graphiques et rapports complexes
  • Sections analytiques indépendantes avec leurs propres états de chargement

3. Agrégateurs de contenu

  • Chargement rapide des titres et des images
  • Chargement différé du contenu des articles
  • Priorité au contenu actuel par rapport au contenu archivé

4. Réseaux sociaux et applications de chat

  • Chargement rapide des informations de profil de base
  • Diffusion des messages et des commentaires
  • Chargement des médias en arrière-plan, après le texte

Résumé

Le streaming de données et React Suspense dans l'App Router de Next.js offrent des outils puissants pour créer des applications plus performantes et réactives. Les principaux avantages de cette approche sont :

  1. Meilleure expérience utilisateur - le contenu s'affiche progressivement, donnant l'impression d'une application plus rapide
  2. Optimisation du TTFB et du LCP - les parties critiques de l'UI se chargent plus vite, améliorant les métriques clés de performance
  3. Contrôle granulaire - la possibilité de contrôler précisément l'ordre de chargement du contenu
  4. Gestion souple de l'UI - une meilleure gestion des états de chargement pour les différentes sections de la page

Lors de la mise en œuvre du streaming de données dans tes applications Next.js, garde à l'esprit les bonnes pratiques suivantes :

  1. Découpe l'UI en parties logiques et indépendantes - chaque partie doit être enveloppée dans son propre composant Suspense
  2. Priorise le contenu critique - les éléments les plus importants doivent être chargés en premier
  3. Récupération de données en parallèle - initialise les requêtes en avance pour éviter le chargement en cascade
  4. Assure des états de chargement fluides - utilise des skeletons aux dimensions appropriées pour éviter les décalages de mise en page
  5. Gère correctement les erreurs - utilise des Error Boundaries pour chaque section afin d'isoler les problèmes

Dans le module suivant, nous verrons en détail comment créer des skeletons de chargement et des indicateurs de progression avancés qui complètent la stratégie de streaming de données et offrent une expérience utilisateur encore meilleure.

Code de cette leçon : App.tsx
1import React, { useState, useEffect, Suspense } from 'react';
2
3// Simulation de Streaming et React.Suspense dans Next.js
4
5function SlowComponent({ label, delay }: { label: string; delay: number }) {
6  const [data, setData] = useState<string | null>(null);
7
8  useEffect(() => {
9    const timer = setTimeout(() => {
10      setData(`Données chargées après ${delay}ms`);
11    }, delay);
12    return () => clearTimeout(timer);
13  }, [delay]);
14
15  if (!data) throw new Promise(() => {}); // simulation pending
16  return (
17    <div style={{ background: '#1a2744', border: '1px solid #64ffda', borderRadius: 8, padding: 16, marginBottom: 12 }}>
18      <h3 style={{ color: '#64ffda', margin: 0 }}>{label}</h3>
19      <p style={{ color: '#ccc', margin: '8px 0 0' }}>{data}</p>
20    </div>
21  );
22}
23
24// Composant de chargement (fallback pour Suspense)
25function StreamingFallback({ label }: { label: string }) {
26  return (
27    <div style={{
28      background: '#1a1a2e', border: '1px dashed #555',
29      borderRadius: 8, padding: 16, marginBottom: 12
30    }}>
31      <div style={{ color: '#888' }}>Streaming: {label}...</div>
32      <div style={{
33        height: 12, background: 'linear-gradient(90deg, #333, #555, #333)',
34        borderRadius: 4, marginTop: 8, animation: 'pulse 1.5s infinite'
35      }} />
36    </div>
37  );
38}
39
40// Composant de démonstration avec différents temps de chargement
41function DemoSection({ label, delay }: { label: string; delay: number }) {
42  const [key, setKey] = useState(0);
43  return (
44    <div>
45      <button onClick={() => setKey(k => k + 1)} style={{
46        background: '#2196f3', color: '#fff', border: 'none',
47        borderRadius: 4, padding: '4px 10px', marginBottom: 8, cursor: 'pointer', fontSize: 11
48      }}>
49        Recharger {label}
50      </button>
51      <ErrorBoundary key={key} fallback={<StreamingFallback label={label} />}>
52        <Suspense fallback={<StreamingFallback label={label} />}>
53          <SlowComponent label={label} delay={delay} key={key} />
54        </Suspense>
55      </ErrorBoundary>
56    </div>
57  );
58}
59
60// Simple limite d'erreurs
61class ErrorBoundary extends React.Component<
62  { children: React.ReactNode; fallback: React.ReactNode },
63  { hasError: boolean }
64> {
65  state = { hasError: false };
66  static getDerivedStateFromError() { return { hasError: true }; }
67  render() {
68    return this.state.hasError ? this.props.fallback : this.props.children;
69  }
70}
71
72export default function StreamingDemo() {
73  return (
74    <div style={{ background: '#0f0f23', minHeight: '100vh', padding: 20, color: '#e0e0e0', fontFamily: 'monospace' }}>
75      <h1 style={{ color: '#64ffda' }}>Streaming et Suspense</h1>
76      <p style={{ color: '#888', marginBottom: 20 }}>
77        Chaque section se charge indépendamment - c'est ça, le streaming !
78      </p>
79      <DemoSection label="Navigation (rapide)" delay={500} />
80      <DemoSection label="Liste de produits (moyenne)" delay={1500} />
81      <DemoSection label="Recommandations IA (lente)" delay={3000} />
82    </div>
83  );
84}

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Le SSG dans Next.js est :

  2. 2. L'ISR dans Next.js signifie :

Tâches pratiques dans le jeu

  • Éditeur de code

    Implémente un tableau de bord avec Streaming SSR et Suspense

  • Tri vertical

    Place les étapes de rendu d'un Server Component dans le bon ordre :

  • Clique dans l'ordre

    Reconstitue la syntaxe de fetch avec l'option revalidate dans Next.js

  • Éditeur de code

    Crée un Server Component qui récupère des données de façon asynchrone et les affiche sur la page

Articles utiles