Streaming

Dans le chapitre précédent, vous avez découvert les différentes méthodes de rendu de Next.js. Nous avons également abordé comment les requêtes de données lentes peuvent impacter les performances de votre application. Voyons comment améliorer l'expérience utilisateur lors de requêtes de données lentes.

Qu'est-ce que le streaming ?

Le streaming est une technique de transfert de données qui vous permet de décomposer une route en plus petits "morceaux" et de les diffuser progressivement du serveur vers le client au fur et à mesure qu'ils sont prêts.

Diagramme montrant le temps avec récupération séquentielle et parallèle des données

En utilisant le streaming, vous pouvez empêcher les requêtes de données lentes de bloquer toute votre page. Cela permet à l'utilisateur de voir et d'interagir avec certaines parties de la page sans attendre que toutes les données soient chargées avant qu'une interface utilisateur ne soit affichée.

Diagramme montrant le temps avec récupération séquentielle et parallèle des données

Le streaming fonctionne bien avec le modèle de composants de React, car chaque composant peut être considéré comme un morceau.

Il existe deux façons d'implémenter le streaming dans Next.js :

  1. Au niveau de la page, avec le fichier loading.tsx (qui crée <Suspense> pour vous).
  2. Au niveau du composant, avec <Suspense> pour un contrôle plus granulaire.

Voyons comment cela fonctionne.

Streaming d'une page entière avec loading.tsx

Dans le dossier /app/dashboard, créez un nouveau fichier appelé loading.tsx :

/app/dashboard/loading.tsx
export default function Loading() {
  return <div>Chargement...</div>;
}

Actualisez http://localhost:3000/dashboard, et vous devriez maintenant voir :

Page Dashboard avec texte 'Chargement...'

Plusieurs choses se passent ici :

  1. loading.tsx est un fichier spécial Next.js basé sur React Suspense. Il vous permet de créer une interface de remplacement à afficher pendant le chargement du contenu de la page.
  2. Comme <SideNav> est statique, il est affiché immédiatement. L'utilisateur peut interagir avec <SideNav> pendant que le contenu dynamique se charge.
  3. L'utilisateur n'a pas besoin d'attendre que la page termine de charger avant de naviguer ailleurs (cela s'appelle une navigation interruptible).

Félicitations ! Vous venez d'implémenter le streaming. Mais nous pouvons faire mieux pour améliorer l'expérience utilisateur. Affichons un squelette de chargement au lieu du texte Chargement….

Ajout de squelettes de chargement

Un squelette de chargement est une version simplifiée de l'interface utilisateur. De nombreux sites web les utilisent comme espace réservé (ou remplacement) pour indiquer aux utilisateurs que le contenu est en cours de chargement. Toute interface utilisateur que vous ajoutez dans loading.tsx sera intégrée dans le fichier statique et envoyée en premier. Ensuite, le reste du contenu dynamique sera diffusé du serveur vers le client.

Dans votre fichier loading.tsx, importez un nouveau composant appelé <DashboardSkeleton> :

/app/dashboard/loading.tsx
import DashboardSkeleton from '@/app/ui/skeletons';
 
export default function Loading() {
  return <DashboardSkeleton />;
}

Ensuite, actualisez http://localhost:3000/dashboard, et vous devriez maintenant voir :

Page Dashboard avec squelettes de chargement

Correction du bug de squelette de chargement avec les groupes de routes

Actuellement, votre squelette de chargement s'appliquera également aux factures.

Comme loading.tsx est à un niveau supérieur à /invoices/page.tsx et /customers/page.tsx dans l'arborescence des fichiers, il s'applique également à ces pages.

Nous pouvons changer cela avec les Groupes de routes. Créez un nouveau dossier appelé /(overview) dans le dossier dashboard. Ensuite, déplacez vos fichiers loading.tsx et page.tsx dans ce dossier :

Structure de dossiers montrant comment créer un groupe de routes avec des parenthèses

Maintenant, le fichier loading.tsx ne s'appliquera qu'à votre page de vue d'ensemble du tableau de bord.

Les groupes de routes vous permettent d'organiser les fichiers en groupes logiques sans affecter la structure du chemin URL. Lorsque vous créez un nouveau dossier avec des parenthèses (), le nom ne sera pas inclus dans le chemin URL. Ainsi, /dashboard/(overview)/page.tsx devient /dashboard.

Ici, vous utilisez un groupe de routes pour vous assurer que loading.tsx ne s'applique qu'à votre page de vue d'ensemble du tableau de bord. Cependant, vous pouvez également utiliser des groupes de routes pour séparer votre application en sections (par exemple, routes (marketing) et routes (shop)) ou par équipes pour les applications plus grandes.

Streaming d'un composant

Jusqu'à présent, vous diffusez une page entière. Mais vous pouvez également être plus granulaire et diffuser des composants spécifiques en utilisant React Suspense.

Suspense vous permet de différer le rendu de certaines parties de votre application jusqu'à ce qu'une condition soit remplie (par exemple, les données sont chargées). Vous pouvez encapsuler vos composants dynamiques dans Suspense. Ensuite, passez-lui un composant de remplacement à afficher pendant le chargement du composant dynamique.

Si vous vous souvenez de la requête de données lente, fetchRevenue(), c'est cette requête qui ralentit toute la page. Au lieu de bloquer toute votre page, vous pouvez utiliser Suspense pour diffuser uniquement ce composant et afficher immédiatement le reste de l'interface utilisateur de la page.

Pour ce faire, vous devrez déplacer la récupération des données vers le composant. Mettons à jour le code pour voir à quoi cela ressemblera :

Supprimez toutes les instances de fetchRevenue() et ses données de /dashboard/(overview)/page.tsx :

/app/dashboard/(overview)/page.tsx
import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
import { fetchLatestInvoices, fetchCardData } from '@/app/lib/data'; // supprimez fetchRevenue
 
export default async function Page() {
  const revenue = await fetchRevenue() // supprimez cette ligne
  const latestInvoices = await fetchLatestInvoices();
  const {
    numberOfInvoices,
    numberOfCustomers,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();
 
  return (
    // ...
  );
}

Ensuite, importez <Suspense> depuis React et encapsulez-le autour de <RevenueChart />. Vous pouvez lui passer un composant de remplacement appelé <RevenueChartSkeleton>.

/app/dashboard/(overview)/page.tsx
import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
import { fetchLatestInvoices, fetchCardData } from '@/app/lib/data';
import { Suspense } from 'react';
import { RevenueChartSkeleton } from '@/app/ui/skeletons';
 
export default async function Page() {
  const latestInvoices = await fetchLatestInvoices();
  const {
    numberOfInvoices,
    numberOfCustomers,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();
 
  return (
    <main>
      <h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
        Tableau de bord
      </h1>
      <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
        <Card title="Collectés" value={totalPaidInvoices} type="collected" />
        <Card title="En attente" value={totalPendingInvoices} type="pending" />
        <Card title="Total des factures" value={numberOfInvoices} type="invoices" />
        <Card
          title="Total des clients"
          value={numberOfCustomers}
          type="customers"
        />
      </div>
      <div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
        <Suspense fallback={<RevenueChartSkeleton />}>
          <RevenueChart />
        </Suspense>
        <LatestInvoices latestInvoices={latestInvoices} />
      </div>
    </main>
  );
}

Enfin, mettez à jour le composant <RevenueChart> pour qu'il récupère ses propres données et supprimez la prop qui lui est passée :

/app/ui/dashboard/revenue-chart.tsx
import { generateYAxis } from '@/app/lib/utils';
import { CalendarIcon } from '@heroicons/react/24/outline';
import { lusitana } from '@/app/ui/fonts';
import { fetchRevenue } from '@/app/lib/data';
 
// ...
 
export default async function RevenueChart() { // Rendez le composant async, supprimez les props
  const revenue = await fetchRevenue(); // Récupérez les données à l'intérieur du composant
 
  const chartHeight = 350;
  const { yAxisLabels, topLabel } = generateYAxis(revenue);
 
  if (!revenue || revenue.length === 0) {
    return <p className="mt-4 text-gray-400">Aucune donnée disponible.</p>;
  }
 
  return (
    // ...
  );
}
 

Maintenant, actualisez la page, vous devriez voir les informations du tableau de bord presque immédiatement, tandis qu'un squelette de remplacement est affiché pour <RevenueChart> :

Page Dashboard avec squelette de graphique des revenus et composants Card et Latest Invoices chargés

Pratique : Streaming de <LatestInvoices>

Maintenant, c'est à votre tour ! Pratiquez ce que vous venez d'apprendre en diffusant le composant <LatestInvoices>.

Déplacez fetchLatestInvoices() de la page vers le composant <LatestInvoices>. Encapsulez le composant dans une limite <Suspense> avec un composant de remplacement appelé <LatestInvoicesSkeleton>.

Une fois prêt, développez le toggle pour voir le code solution :

Regroupement de composants

Super ! Vous y êtes presque, maintenant vous devez encapsuler les composants <Card> dans Suspense. Vous pouvez récupérer les données pour chaque carte individuellement, mais cela pourrait entraîner un effet de popping lorsque les cartes se chargent, ce qui peut être visuellement désagréable pour l'utilisateur.

Alors, comment résoudre ce problème ?

Pour créer un effet plus étagé, vous pouvez regrouper les cartes à l'aide d'un composant wrapper. Cela signifie que le <SideNav/> statique sera affiché en premier, suivi des cartes, etc.

Dans votre fichier page.tsx :

  1. Supprimez vos composants <Card>.
  2. Supprimez la fonction fetchCardData().
  3. Importez un nouveau composant wrapper appelé <CardWrapper />.
  4. Importez un nouveau composant squelette appelé <CardsSkeleton />.
  5. Encapsulez <CardWrapper /> dans Suspense.
/app/dashboard/(overview)/page.tsx
import CardWrapper from '@/app/ui/dashboard/cards';
// ...
import {
  RevenueChartSkeleton,
  LatestInvoicesSkeleton,
  CardsSkeleton,
} from '@/app/ui/skeletons';
 
export default async function Page() {
  return (
    <main>
      <h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
        Tableau de bord
      </h1>
      <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
        <Suspense fallback={<CardsSkeleton />}>
          <CardWrapper />
        </Suspense>
      </div>
      // ...
    </main>
  );
}

Ensuite, allez dans le fichier /app/ui/dashboard/cards.tsx, importez la fonction fetchCardData() et invoquez-la à l'intérieur du composant <CardWrapper/>. Assurez-vous de décommenter tout code nécessaire dans ce composant.

/app/ui/dashboard/cards.tsx
// ...
import { fetchCardData } from '@/app/lib/data';
 
// ...
 
export default async function CardWrapper() {
  const {
    numberOfInvoices,
    numberOfCustomers,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();
 
  return (
    <>
      <Card title="Collectés" value={totalPaidInvoices} type="collected" />
      <Card title="En attente" value={totalPendingInvoices} type="pending" />
      <Card title="Total des factures" value={numberOfInvoices} type="invoices" />
      <Card
        title="Total des clients"
        value={numberOfCustomers}
        type="customers"
      />
    </>
  );
}

Actualisez la page, et vous devriez voir toutes les cartes se charger en même temps. Vous pouvez utiliser ce modèle lorsque vous souhaitez que plusieurs composants se chargent simultanément.

Décider où placer vos limites Suspense

L'emplacement de vos limites Suspense dépendra de quelques éléments :

  1. Comment vous souhaitez que l'utilisateur expérimente la page pendant le streaming.
  2. Quel contenu vous souhaitez prioriser.
  3. Si les composants dépendent de la récupération de données.

Jetez un œil à votre page de tableau de bord, y a-t-il quelque chose que vous auriez fait différemment ?

Ne vous inquiétez pas. Il n'y a pas de bonne réponse.

  • Vous pourriez diffuser la page entière comme nous l'avons fait avec loading.tsx... mais cela pourrait entraîner un temps de chargement plus long si l'un des composants a une récupération de données lente.
  • Vous pourriez diffuser chaque composant individuellement... mais cela pourrait entraîner un effet de popping de l'interface utilisateur à l'écran au fur et à mesure qu'elle devient prête.
  • Vous pourriez également créer un effet étagé en diffusant des sections de page. Mais vous devrez créer des composants wrapper.

L'emplacement de vos limites Suspense variera en fonction de votre application. En général, il est bon de déplacer vos récupérations de données vers les composants qui en ont besoin, puis d'encapsuler ces composants dans Suspense. Mais il n'y a rien de mal à diffuser les sections ou la page entière si c'est ce dont votre application a besoin.

N'hésitez pas à expérimenter avec Suspense et à voir ce qui fonctionne le mieux, c'est une API puissante qui peut vous aider à créer des expériences utilisateur plus agréables.

Perspectives

Le streaming et les composants serveur nous offrent de nouvelles façons de gérer la récupération de données et les états de chargement, avec pour objectif ultime d'améliorer l'expérience utilisateur finale.

Dans le prochain chapitre, vous découvrirez le Prerendering Partiel, un nouveau modèle de rendu Next.js conçu avec le streaming à l'esprit.