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

Skeletons de chargement et indicateurs de progression

19 min de lecture
Dans cette leçon12

Dans le module précédent, nous avons vu comment utiliser React Suspense et le streaming de données pour améliorer les performances de chargement et la réactivité de l'interface utilisateur. Cependant, attendre simplement le chargement des données sans informer correctement l'utilisateur de ce qui se passe peut générer de la frustration et donner l'impression que l'application ne fonctionne pas. Dans ce module, nous nous concentrerons sur la création de skeletons de chargement et d'indicateurs de progression avancés qui complètent la stratégie de streaming de données et garantissent une excellente expérience utilisateur.

Pourquoi les skeletons de chargement sont-ils importants ?

Les skeletons de chargement et les indicateurs de progression sont des éléments clés des applications web modernes, pour plusieurs raisons :

  1. Ils réduisent la latence perçue - même si le temps de chargement réel ne change pas, les utilisateurs le perçoivent comme plus court lorsqu'ils voient qu'il se passe quelque chose
  2. Ils évitent les décalages de mise en page (CLS) - ils préservent la structure de la page, réduisant les décalages pendant le chargement
  3. Ils communiquent l'état de l'application - ils indiquent à l'utilisateur que les données se chargent, au lieu de le laisser dans l'incertitude
  4. Ils améliorent les Core Web Vitals - un CLS plus faible et un meilleur LCP (Largest Contentful Paint) se traduisent par de meilleurs scores de performance

Types d'indicateurs d'état de chargement

Il existe plusieurs approches pour informer l'utilisateur de l'état de chargement :

  1. Skeletons de chargement (Skeleton UI) - représentations graphiques simplifiées du contenu qui sera affiché
  2. Indicateurs de progression - représentations visuelles de l'avancement du chargement (barres, cercles)
  3. Loaders indéterminés - indicateurs animés (par ex. cercles qui tournent) qui n'affichent pas de progression précise
  4. Rendu différé - afficher les indicateurs de chargement seulement après un certain temps pour éviter le scintillement
  5. Animations de transition - transitions fluides entre les états de chargement et le contenu prêt

Implémenter des skeletons de chargement dans Next.js

1. Composants skeleton de base

Commençons par créer des composants skeleton de base que l'on pourra utiliser comme espaces réservés pendant le chargement :

1// components/ui/skeleton.tsx
2import { cn } from '@/lib/utils';
3
4interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {}
5
6export function Skeleton({ className, ...props }: SkeletonProps) {
7  return (
8    <div
9      className={cn(
10        "animate-pulse rounded-md bg-gray-200 dark:bg-gray-800",
11        className
12      )}
13      {...props}
14    />
15  );
16}

Ce composant simple crée un élément pulsant que l'on peut personnaliser avec des classes et des propriétés. On peut maintenant l'utiliser pour créer différents types de skeletons de chargement.

2. Skeletons avancés pour des types de contenu spécifiques

On peut créer des skeletons plus avancés pour des types de contenu précis, comme des cartes produit, des tableaux ou des listes :

1// components/skeletons/product-card-skeleton.tsx
2import { Skeleton } from "@/components/ui/skeleton";
3
4export function ProductCardSkeleton() {
5  return (
6    <div className="space-y-4 rounded-lg border p-4">
7      {/* Skeleton de l'image du produit */}
8      <Skeleton className="h-[200px] w-full rounded-lg" />
9
10      {/* Skeleton du nom du produit */}
11      <Skeleton className="h-8 w-3/4" />
12
13      {/* Skeleton du prix et de la note */}
14      <div className="flex justify-between">
15        <Skeleton className="h-6 w-1/4" />
16        <Skeleton className="h-6 w-1/4" />
17      </div>
18
19      {/* Skeleton du bouton */}
20      <Skeleton className="h-10 w-full" />
21    </div>
22  );
23}
1// components/skeletons/table-skeleton.tsx
2import { Skeleton } from "@/components/ui/skeleton";
3
4interface TableSkeletonProps {
5  rows?: number;
6  columns?: number;
7}
8
9export function TableSkeleton({ rows = 5, columns = 4 }: TableSkeletonProps) {
10  return (
11    <div className="w-full space-y-3 rounded-lg border p-4">
12      {/* En-tête du tableau */}
13      <div className="flex gap-3 pb-2">
14        {Array.from({ length: columns }).map((_, i) => (
15          <Skeleton key={`header-${i}`} className="h-10 flex-1" />
16        ))}
17      </div>
18
19      {/* Lignes du tableau */}
20      {Array.from({ length: rows }).map((_, rowIndex) => (
21        <div key={`row-${rowIndex}`} className="flex gap-3">
22          {Array.from({ length: columns }).map((_, colIndex) => (
23            <Skeleton
24              key={`cell-${rowIndex}-${colIndex}`}
25              className="h-12 flex-1"
26            />
27          ))}
28        </div>
29      ))}
30    </div>
31  );
32}

3. Utiliser les skeletons avec React Suspense

On peut maintenant combiner nos skeletons de chargement avec React Suspense pour les utiliser comme fallbacks pendant le streaming de données :

1// app/products/page.tsx
2import { Suspense } from "react";
3import { ProductGrid } from "./product-grid";
4import { ProductFilters } from "./product-filters";
5import { ProductGridSkeleton } from "@/components/skeletons/product-grid-skeleton";
6import { ProductFiltersSkeleton } from "@/components/skeletons/product-filters-skeleton";
7
8export default function ProductsPage() {
9  return (
10    <div className="container mx-auto py-10">
11      <h1 className="mb-8 text-3xl font-bold">Nos produits</h1>
12
13      <div className="grid grid-cols-4 gap-6">
14        <div className="col-span-1">
15          <Suspense fallback={<ProductFiltersSkeleton />}>
16            <ProductFilters />
17          </Suspense>
18        </div>
19
20        <div className="col-span-3">
21          <Suspense fallback={<ProductGridSkeleton />}>
22            <ProductGrid />
23          </Suspense>
24        </div>
25      </div>
26    </div>
27  );
28}

Dans cet exemple, on crée des skeletons pour les filtres et la grille de produits, afin que l'utilisateur voie la structure appropriée de la page pendant le chargement.

Indicateurs de progression dans Next.js

En plus des skeletons de chargement, on peut aussi utiliser des indicateurs de progression pour informer l'utilisateur de l'état des opérations, surtout celles qui prennent plus de temps.

1. Indicateur de progression simple

1// components/ui/progress.tsx
2"use client";
3
4import * as React from "react";
5import * as ProgressPrimitive from "@radix-ui/react-progress";
6import { cn } from "@/lib/utils";
7
8interface ProgressProps extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {
9  indicatorClassName?: string;
10}
11
12const Progress = React.forwardRef<
13  React.ElementRef<typeof ProgressPrimitive.Root>,
14  ProgressProps
15>(({ className, value, indicatorClassName, ...props }, ref) => (
16  <ProgressPrimitive.Root
17    ref={ref}
18    className={cn(
19      "relative h-2 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800",
20      className
21    )}
22    {...props}
23  >
24    <ProgressPrimitive.Indicator
25      className={cn(
26        "h-full w-full flex-1 bg-blue-600 transition-all dark:bg-blue-400",
27        indicatorClassName
28      )}
29      style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
30    />
31  </ProgressPrimitive.Root>
32));
33Progress.displayName = ProgressPrimitive.Root.displayName;
34
35export { Progress };

2. Indicateur de chargement indéterminé (Spinner)

1// components/ui/spinner.tsx
2import { cn } from "@/lib/utils";
3
4interface SpinnerProps extends React.HTMLAttributes<HTMLDivElement> {
5  size?: "sm" | "md" | "lg";
6  variant?: "primary" | "secondary";
7}
8
9export function Spinner({
10  className,
11  size = "md",
12  variant = "primary",
13  ...props
14}: SpinnerProps) {
15  const sizeClasses = {
16    sm: "h-4 w-4 border-2",
17    md: "h-6 w-6 border-2",
18    lg: "h-10 w-10 border-3",
19  };
20
21  const variantClasses = {
22    primary: "border-blue-600 border-t-transparent",
23    secondary: "border-gray-600 border-t-transparent",
24  };
25
26  return (
27    <div
28      className={cn(
29        "inline-block animate-spin rounded-full",
30        sizeClasses[size],
31        variantClasses[variant],
32        className
33      )}
34      role="status"
35      aria-label="loading"
36      {...props}
37    >
38      <span className="sr-only">Chargement...</span>
39    </div>
40  );
41}

Indicateurs de progression à l'échelle de l'application

Dans Next.js, on peut aussi implémenter des indicateurs de progression globaux qui informent l'utilisateur de l'état de la navigation entre les pages.

1. Indicateur de progression global pour les transitions de page

1// components/global-progress.tsx
2"use client";
3
4import { useEffect, useState } from "react";
5import { usePathname, useSearchParams } from "next/navigation";
6import NProgress from "nprogress";
7import "nprogress/nprogress.css";
8
9export function GlobalProgress() {
10  const pathname = usePathname();
11  const searchParams = useSearchParams();
12  const [isNavigating, setIsNavigating] = useState(false);
13
14  useEffect(() => {
15    // Configuration de NProgress
16    NProgress.configure({ showSpinner: false });
17
18    // Gestion du début de la navigation
19    function onRouteChangeStart() {
20      setIsNavigating(true);
21      NProgress.start();
22    }
23
24    // Gestion de la fin de la navigation
25    function onRouteChangeComplete() {
26      setIsNavigating(false);
27      NProgress.done();
28    }
29
30    // Ajout des écouteurs d'événements
31    window.addEventListener("beforeunload", onRouteChangeStart);
32
33    // Retrait des écouteurs au démontage du composant
34    return () => {
35      window.removeEventListener("beforeunload", onRouteChangeStart);
36    };
37  }, []);
38
39  // Réaction aux changements de chemin ou de paramètres de recherche
40  useEffect(() => {
41    if (isNavigating) {
42      NProgress.done();
43      setIsNavigating(false);
44    }
45  }, [pathname, searchParams, isNavigating]);
46
47  return null; // Ce composant n'affiche rien, il gère uniquement NProgress
48}

On peut ensuite ajouter ce composant à notre layout.tsx :

1// app/layout.tsx
2import { GlobalProgress } from "@/components/global-progress";
3
4export default function RootLayout({ children }) {
5  return (
6    <html lang="en">
7      <body>
8        <GlobalProgress />
9        {children}
10      </body>
11    </html>
12  );
13}

Techniques avancées de gestion de l'état de chargement

1. Rendu différé de l'indicateur de chargement

Dans certains cas, le chargement des données est si rapide qu'afficher un indicateur provoque un scintillement inutile de l'interface. On peut résoudre ce problème en différant le rendu de l'indicateur de chargement :

1// hooks/use-delayed-loading.tsx
2import { useState, useEffect } from "react";
3
4export function useDelayedLoading(loading: boolean, delay = 300) {
5  const [showLoading, setShowLoading] = useState(false);
6
7  useEffect(() => {
8    if (loading) {
9      const timer = setTimeout(() => {
10        setShowLoading(true);
11      }, delay);
12
13      return () => clearTimeout(timer);
14    } else {
15      setShowLoading(false);
16    }
17  }, [loading, delay]);
18
19  return showLoading && loading;
20}

On peut maintenant utiliser ce hook dans nos composants :

1// components/delayed-loading.tsx
2"use client";
3
4import { useDelayedLoading } from "@/hooks/use-delayed-loading";
5
6interface DelayedLoadingProps {
7  loading: boolean;
8  delay?: number;
9  fallback: React.ReactNode;
10  children: React.ReactNode;
11}
12
13export function DelayedLoading({
14  loading,
15  delay = 300,
16  fallback,
17  children
18}: DelayedLoadingProps) {
19  const showLoading = useDelayedLoading(loading, delay);
20
21  return showLoading ? fallback : children;
22}

2. Suspense personnalisé avec délai

On peut aussi créer un composant personnalisé basé sur Suspense qui introduit un délai :

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

Gérer le Cumulative Layout Shift (CLS)

Les décalages de mise en page (CLS) sont l'une des métriques clés des Core Web Vitals. Des skeletons de chargement bien conçus permettent de minimiser ces décalages en préservant les dimensions et les proportions des éléments.

1. Préserver les dimensions

1// components/skeletons/image-skeleton.tsx
2import { Skeleton } from "@/components/ui/skeleton";
3
4interface ImageSkeletonProps {
5  width: number;
6  height: number;
7  aspectRatio?: number;
8}
9
10export function ImageSkeleton({ width, height, aspectRatio }: ImageSkeletonProps) {
11  return (
12    <div
13      className="overflow-hidden rounded-lg"
14      style={{
15        width: width ? `${width}px` : '100%',
16        height: height ? `${height}px` : '100%',
17        aspectRatio: aspectRatio ? `${aspectRatio}` : undefined,
18      }}
19    >
20      <Skeleton className="h-full w-full" />
21    </div>
22  );
23}

2. Charger des images en préservant l'espace

1// components/ui/image.tsx
2"use client";
3
4import { useState } from "react";
5import Image, { ImageProps } from "next/image";
6import { Skeleton } from "@/components/ui/skeleton";
7
8interface OptimizedImageProps extends Omit<ImageProps, "onLoad" | "onError"> {
9  fallbackClassName?: string;
10}
11
12export function OptimizedImage({
13  fallbackClassName,
14  className,
15  alt,
16  ...props
17}: OptimizedImageProps) {
18  const [isLoading, setIsLoading] = useState(true);
19
20  return (
21    <div className="relative">
22      {isLoading && (
23        <Skeleton
24          className={fallbackClassName || className}
25          style={{
26            position: "absolute",
27            top: 0,
28            left: 0,
29            right: 0,
30            bottom: 0,
31          }}
32        />
33      )}
34      <Image
35        className={className}
36        alt={alt}
37        onLoad={() => setIsLoading(false)}
38        {...props}
39      />
40    </div>
41  );
42}

Indicateurs d'état d'envoi de formulaire

En plus des indicateurs de chargement pour les données entrantes, il est aussi utile d'afficher l'état des opérations d'envoi de données.

1. Bouton avec indicateur de chargement

1// components/ui/loading-button.tsx
2import { ButtonHTMLAttributes } from "react";
3import { Button } from "@/components/ui/button";
4import { Spinner } from "@/components/ui/spinner";
5import { cn } from "@/lib/utils";
6
7interface LoadingButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
8  loading?: boolean;
9  variant?: "default" | "outline" | "ghost" | "link";
10  size?: "default" | "sm" | "lg";
11  spinnerClassName?: string;
12}
13
14export function LoadingButton({
15  children,
16  loading,
17  className,
18  spinnerClassName,
19  disabled,
20  ...props
21}: LoadingButtonProps) {
22  return (
23    <Button
24      className={cn("relative", className)}
25      disabled={disabled || loading}
26      {...props}
27    >
28      {loading && (
29        <span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
30          <Spinner size="sm" className={spinnerClassName} />
31        </span>
32      )}
33      <span className={cn(loading ? "invisible" : "")}>{children}</span>
34    </Button>
35  );
36}

2. Formulaire avec indicateur d'état

1// components/submit-form.tsx
2"use client";
3
4import { useState } from "react";
5import { useRouter } from "next/navigation";
6import { LoadingButton } from "@/components/ui/loading-button";
7
8export function ContactForm() {
9  const [loading, setLoading] = useState(false);
10  const [success, setSuccess] = useState(false);
11  const [error, setError] = useState<string | null>(null);
12  const router = useRouter();
13
14  async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
15    event.preventDefault();
16    setLoading(true);
17    setError(null);
18
19    try {
20      const formData = new FormData(event.currentTarget);
21      const response = await fetch('/api/contact', {
22        method: 'POST',
23        body: formData,
24      });
25
26      if (!response.ok) {
27        throw new Error('An error occurred while sending the message');
28      }
29
30      setSuccess(true);
31      event.currentTarget.reset();
32
33      // Redirection après un envoi réussi
34      setTimeout(() => {
35        router.push('/contact/thank-you');
36      }, 1500);
37    } catch (err) {
38      setError(err instanceof Error ? err.message : 'An unknown error occurred');
39    } finally {
40      setLoading(false);
41    }
42  }
43
44  return (
45    <form onSubmit={handleSubmit} className="space-y-6">
46      {/* Champs du formulaire */}
47      <div className="space-y-4">
48        <input
49          type="text"
50          name="name"
51          required
52          className="w-full rounded-md border p-3"
53          disabled={loading}
54        />
55        <input
56          type="email"
57          name="email"
58          required
59          className="w-full rounded-md border p-3"
60          disabled={loading}
61        />
62        <textarea
63          name="message"
64          required
65          className="h-32 w-full rounded-md border p-3"
66          disabled={loading}
67        />
68      </div>
69
70      {/* État du formulaire */}
71      {error && (
72        <div className="rounded-md bg-red-50 p-4 text-red-600">
73          {error}
74        </div>
75      )}
76
77      {success && (
78        <div className="rounded-md bg-green-50 p-4 text-green-600">
79          Message envoyé avec succès !
80        </div>
81      )}
82
83      {/* Bouton d'envoi */}
84      <LoadingButton
85        type="submit"
86        loading={loading}
87        className="w-full"
88      >
89        Envoyer le message
90      </LoadingButton>
91    </form>
92  );
93}

Indicateurs de progression pour les opérations longues

Pour les opérations qui durent plus longtemps, il est utile d'afficher des indicateurs de progression qui informent l'utilisateur de l'état actuel et du temps restant.

1. Indicateur de progression en pourcentage

1// components/ui/percent-progress.tsx
2"use client";
3
4import { Progress } from "@/components/ui/progress";
5
6interface PercentProgressProps {
7  value: number;
8  max: number;
9  label?: string;
10  showPercent?: boolean;
11  className?: string;
12}
13
14export function PercentProgress({
15  value,
16  max,
17  label,
18  showPercent = true,
19  className
20}: PercentProgressProps) {
21  const percent = Math.min(Math.round((value / max) * 100), 100);
22
23  return (
24    <div className={className}>
25      {label && (
26        <div className="mb-2 flex justify-between">
27          <span>{label}</span>
28          {showPercent && <span>{percent}%</span>}
29        </div>
30      )}
31      <Progress value={percent} />
32    </div>
33  );
34}

2. Indicateur de progression de l'envoi de fichier

1// components/file-upload-progress.tsx
2"use client";
3
4import { useState } from "react";
5import { PercentProgress } from "@/components/ui/percent-progress";
6
7interface FileUploadProgressProps {
8  onUploadComplete: (url: string) => void;
9  accept?: string;
10  maxSizeMB?: number;
11}
12
13export function FileUploadProgress({
14  onUploadComplete,
15  accept = "image/*",
16  maxSizeMB = 5
17}: FileUploadProgressProps) {
18  const [progress, setProgress] = useState(0);
19  const [uploading, setUploading] = useState(false);
20  const [error, setError] = useState<string | null>(null);
21
22  async function handleUpload(event: React.ChangeEvent<HTMLInputElement>) {
23    const file = event.target.files?.[0];
24    if (!file) return;
25
26    // Vérifie la taille du fichier
27    if (file.size > maxSizeMB * 1024 * 1024) {
28      setError(`The file is too large. Maximum size is ${maxSizeMB}MB`);
29      return;
30    }
31
32    setUploading(true);
33    setError(null);
34    setProgress(0);
35
36    try {
37      const formData = new FormData();
38      formData.append('file', file);
39
40      const xhr = new XMLHttpRequest();
41
42      // Écoute de la progression de l'envoi
43      xhr.upload.addEventListener('progress', (event) => {
44        if (event.lengthComputable) {
45          const percentComplete = (event.loaded / event.total) * 100;
46          setProgress(percentComplete);
47        }
48      });
49
50      // Gestion de la fin de l'envoi
51      xhr.addEventListener('load', () => {
52        if (xhr.status === 200) {
53          const response = JSON.parse(xhr.responseText);
54          onUploadComplete(response.url);
55        } else {
56          setError('An error occurred while uploading the file');
57        }
58        setUploading(false);
59      });
60
61      // Gestion des erreurs
62      xhr.addEventListener('error', () => {
63        setError('A network error occurred while uploading the file');
64        setUploading(false);
65      });
66
67      // Envoi du fichier
68      xhr.open('POST', '/api/upload');
69      xhr.send(formData);
70    } catch (err) {
71      setError('An unexpected error occurred');
72      setUploading(false);
73    }
74  }
75
76  return (
77    <div className="space-y-4">
78      <label className="block">
79        <span className="mb-1 block font-medium">Choisis un fichier :</span>
80        <input
81          type="file"
82          accept={accept}
83          onChange={handleUpload}
84          disabled={uploading}
85          className="w-full cursor-pointer rounded-md border p-2 file:mr-4 file:rounded file:border-0 file:bg-blue-50 file:px-4 file:py-2 file:text-sm file:font-medium file:text-blue-600 hover:file:bg-blue-100"
86        />
87      </label>
88
89      {uploading && (
90        <PercentProgress
91          value={progress}
92          max={100}
93          label="Envoi du fichier"
94          showPercent
95        />
96      )}
97
98      {error && (
99        <div className="rounded-md bg-red-50 p-3 text-sm text-red-600">
100          {error}
101        </div>
102      )}
103    </div>
104  );
105}

Intégration avec React Suspense et les Server Components

Dans Next.js, on peut combiner nos skeletons de chargement et nos indicateurs de progression avec React Suspense et les Server Components pour créer une expérience utilisateur fluide.

1. Page produit dynamique avec différents indicateurs de chargement

1// app/products/page.tsx
2import { Suspense } from "react";
3import { ProductHeader } from "./product-header";
4import { ProductGrid } from "./product-grid";
5import { ProductFilters } from "./product-filters";
6import { ProductSearch } from "./product-search";
7import { ProductGridSkeleton } from "@/components/skeletons/product-grid-skeleton";
8import { ProductFiltersSkeleton } from "@/components/skeletons/product-filters-skeleton";
9import { ProductHeaderSkeleton } from "@/components/skeletons/product-header-skeleton";
10import { DelayedSuspense } from "@/components/delayed-suspense";
11
12export default async function ProductsPage({
13  searchParams
14}: {
15  searchParams: Promise<{ [key: string]: string | string[] | undefined }>
16}) {
17  return (
18    <div className="container mx-auto py-10">
19      {/* Priorité 1 : en-tête de page - faible latence */}
20      <Suspense fallback={<ProductHeaderSkeleton />}>
21        <ProductHeader />
22      </Suspense>
23
24      {/* Priorité 2 : barre de recherche - léger délai */}
25      <div className="mb-6">
26        <ProductSearch initialQuery={(await searchParams).q as string} />
27      </div>
28
29      <div className="grid grid-cols-4 gap-6">
30        {/* Priorité 3 : filtres - rendu différé */}
31        <div className="col-span-1">
32          <DelayedSuspense
33            fallback={<ProductFiltersSkeleton />}
34            delay={300}
35          >
36            <ProductFilters
37              initialCategory={(await searchParams).category as string}
38              initialPriceRange={{
39                min: (await searchParams).minPrice as string,
40                max: (await searchParams).maxPrice as string,
41              }}
42            />
43          </DelayedSuspense>
44        </div>
45
46        {/* Priorité 4 : grille de produits - avec skeleton complet */}
47        <div className="col-span-3">
48          <Suspense fallback={<ProductGridSkeleton items={12} />}>
49            <ProductGrid
50              category={(await searchParams).category as string}
51              query={(await searchParams).q as string}
52              sort={(await searchParams).sort as string}
53              minPrice={(await searchParams).minPrice as string}
54              maxPrice={(await searchParams).maxPrice as string}
55            />
56          </Suspense>
57        </div>
58      </div>
59    </div>
60  );
61}

2. Implémentation du composant produit

1// app/products/product-grid.tsx
2import { ProductCard } from "@/components/product-card";
3
4interface ProductGridProps {
5  category?: string;
6  query?: string;
7  sort?: string;
8  minPrice?: string;
9  maxPrice?: string;
10}
11
12async function getProducts(params: ProductGridProps) {
13  // Conversion des paramètres
14  const minPrice = (await params).minPrice ? parseInt((await params).minPrice) : undefined;
15  const maxPrice = (await params).maxPrice ? parseInt((await params).maxPrice) : undefined;
16
17  // Simulation d'un délai réseau pour illustrer le chargement du skeleton
18  await new Promise(resolve => setTimeout(resolve, 1500));
19
20  // Une vraie implémentation récupérerait les données depuis une API ou une base de données
21  const response = await fetch('https://api.example.com/products', {
22    method: 'POST',
23    headers: { 'Content-Type': 'application/json' },
24    body: JSON.stringify({
25      category: (await params).category,
26      query: (await params).query,
27      sort: (await params).sort,
28      filters: { price: { min: minPrice, max: maxPrice } }
29    }),
30    // Dans une vraie implémentation, tu pourrais utiliser cache, revalidate, etc.
31    cache: 'no-store'
32  });
33
34  if (!response.ok) {
35    throw new Error('Problem fetching products');
36  }
37
38  return response.json();
39}
40
41export async function ProductGrid(props: ProductGridProps) {
42  const products = await getProducts(props);
43
44  if (products.length === 0) {
45    return (
46      <div className="py-10 text-center">
47        <h3 className="text-xl font-medium">Aucun produit trouvé</h3>
48        <p className="mt-2 text-gray-500">
49          Essaie de modifier tes critères de recherche ou tes filtres
50        </p>
51      </div>
52    );
53  }
54
55  return (
56    <div className="grid grid-cols-3 gap-6">
57      {products.map(product => (
58        <ProductCard key={product.id} product={product} />
59      ))}
60    </div>
61  );
62}

Indicateurs d'état de chargement dans les Client Components

Dans les composants client, on utilise d'autres méthodes pour afficher les indicateurs d'état de chargement, car on ne peut pas utiliser React Suspense pour la récupération asynchrone de données dans ces composants.

1. Hook useAsyncData pour les indicateurs de chargement

1// hooks/use-async-data.ts
2import { useState, useEffect } from "react";
3
4type AsyncStatus = "idle" | "loading" | "success" | "error";
5
6interface UseAsyncDataResult<T> {
7  data: T | null;
8  status: AsyncStatus;
9  error: Error | null;
10  isLoading: boolean;
11  isSuccess: boolean;
12  isError: boolean;
13  reload: () => void;
14}
15
16export function useAsyncData<T>(
17  fetchFn: () => Promise<T>,
18  dependencies: any[] = []
19): UseAsyncDataResult<T> {
20  const [data, setData] = useState<T | null>(null);
21  const [status, setStatus] = useState<AsyncStatus>("idle");
22  const [error, setError] = useState<Error | null>(null);
23  const [reloadCounter, setReloadCounter] = useState(0);
24
25  function reload() {
26    setReloadCounter(prev => prev + 1);
27  }
28
29  useEffect(() => {
30    let isMounted = true;
31
32    async function loadData() {
33      setStatus("loading");
34      setError(null);
35
36      try {
37        const result = await fetchFn();
38
39        if (isMounted) {
40          setData(result);
41          setStatus("success");
42        }
43      } catch (e) {
44        if (isMounted) {
45          setError(e instanceof Error ? e : new Error(String(e)));
46          setStatus("error");
47        }
48      }
49    }
50
51    loadData();
52
53    return () => {
54      isMounted = false;
55    };
56  }, [...dependencies, reloadCounter]);
57
58  return {
59    data,
60    status,
61    error,
62    isLoading: status === "loading",
63    isSuccess: status === "success",
64    isError: status === "error",
65    reload,
66  };
67}

2. Utiliser le hook dans un Client Component

1// components/user-dashboard.tsx
2"use client";
3
4import { useAsyncData } from "@/hooks/use-async-data";
5import { Spinner } from "@/components/ui/spinner";
6import { ProductCardSkeleton } from "@/components/skeletons/product-card-skeleton";
7
8interface UserDashboardProps {
9  userId: string;
10}
11
12async function fetchUserDashboardData(userId: string) {
13  const response = await fetch(`/api/users/${userId}/dashboard`);
14  if (!response.ok) {
15    throw new Error('Problem fetching dashboard data');
16  }
17  return response.json();
18}
19
20export function UserDashboard({ userId }: UserDashboardProps) {
21  const {
22    data,
23    isLoading,
24    isError,
25    error,
26    reload
27  } = useAsyncData(() => fetchUserDashboardData(userId), [userId]);
28
29  if (isLoading) {
30    return (
31      <div className="space-y-4">
32        <div className="flex items-center justify-between">
33          <h2 className="text-2xl font-bold">Ton tableau de bord</h2>
34          <div>
35            <Spinner size="md" />
36          </div>
37        </div>
38
39        <div className="grid grid-cols-3 gap-4">
40          {Array.from({ length: 6 }).map((_, i) => (
41            <ProductCardSkeleton key={i} />
42          ))}
43        </div>
44      </div>
45    );
46  }
47
48  if (isError) {
49    return (
50      <div className="rounded-lg bg-red-50 p-6 text-center">
51        <h3 className="text-lg font-medium text-red-800">Une erreur est survenue</h3>
52        <p className="mt-2 text-red-600">{error?.message}</p>
53        <button
54          onClick={reload}
55          className="mt-4 rounded-md bg-red-100 px-4 py-2 text-red-800 hover:bg-red-200"
56        >
57          Réessayer
58        </button>
59      </div>
60    );
61  }
62
63  if (!data) {
64    return null;
65  }
66
67  return (
68    <div className="space-y-6">
69      <div className="flex items-center justify-between">
70        <h2 className="text-2xl font-bold">Bienvenue, {data.user.name}</h2>
71        <button
72          onClick={reload}
73          className="rounded-md bg-gray-100 p-2 hover:bg-gray-200"
74          aria-label="Refresh data"
75        >
76          <RefreshIcon className="h-5 w-5" />
77        </button>
78      </div>
79
80      <div className="grid grid-cols-3 gap-4">
81        {data.recentlyViewed.map(product => (
82          <ProductCard key={product.id} product={product} />
83        ))}
84      </div>
85
86      {/* Autres sections du tableau de bord */}
87    </div>
88  );
89}
90
91// Composant icône
92function RefreshIcon({ className }: { className?: string }) {
93  return (
94    <svg
95      xmlns="http://www.w3.org/2000/svg"
96      viewBox="0 0 24 24"
97      fill="none"
98      stroke="currentColor"
99      strokeWidth="2"
100      strokeLinecap="round"
101      strokeLinejoin="round"
102      className={className}
103    >
104      <path d="M21 2v6h-6" />
105      <path d="M3 12a9 9 0 0 1 15-6.7L21 8" />
106      <path d="M3 22v-6h6" />
107      <path d="M21 12a9 9 0 0 1-15 6.7L3 16" />
108    </svg>
109  );
110}

Résumé et bonnes pratiques

Voici les principes clés pour créer des skeletons de chargement et des indicateurs de progression efficaces dans Next.js :

  1. Adapte-les au contenu - les skeletons de chargement doivent refléter la forme et les proportions du contenu réel

  2. Stratégies d'affichage :

    • Pour les opérations rapides (< 300 ms) - envisage de ne pas afficher d'indicateur pour éviter le scintillement
    • Pour les opérations moyennes (300 ms - 1 s) - utilise des skeletons ou des spinners
    • Pour les opérations longues (> 1 s) - utilise des indicateurs de progression avec une progression réelle (si possible)
  3. Priorisation du contenu - affiche d'abord les éléments d'UI critiques :

    • En-tête et navigation
    • Contenu principal de la page
    • Éléments secondaires (filtres, panneaux latéraux) en dernier
  4. Minimise le CLS - définis toujours les dimensions et proportions des skeletons de chargement

  5. Accessibilité - n'oublie pas l'accessibilité des indicateurs de chargement :

    • Utilise les attributs ARIA appropriés (aria-busy, aria-label)
    • Fournis des alternatives textuelles aux indicateurs visuels
    • Évite les animations clignotantes qui peuvent poser problème aux personnes épileptiques
  6. Optimisation pour les appareils mobiles - adapte les indicateurs de chargement aux différentes tailles d'écran et utilise des skeletons appropriés pour les mises en page mobiles

  7. Cohérence visuelle - garde un aspect cohérent entre les skeletons de chargement et le reste de l'interface utilisateur (couleurs, coins arrondis, espacements)

À une époque où les utilisateurs attendent une réponse instantanée, des états de chargement bien conçus sont un élément clé de l'expérience utilisateur. Grâce aux techniques décrites dans ce module, tu peux créer des applications qui semblent fonctionner plus vite et offrent une expérience plus fluide, même lorsque les données se chargent en arrière-plan.

Dans le module suivant, nous aborderons le partial prerendering, qui constitue l'étape suivante de l'optimisation des performances des applications Next.js et permet une expérience utilisateur encore meilleure.

Code de cette leçon : App.tsx
1import React, { useState, useEffect } from 'react';
2
3// Skeleton components et loading states dans Next.js
4
5function SkeletonLine({ width = '100%' }: { width?: string }) {
6  return (
7    <div style={{
8      height: 14, background: 'linear-gradient(90deg, #1a2744 25%, #243b5e 50%, #1a2744 75%)',
9      backgroundSize: '200% 100%', borderRadius: 4, width,
10      animation: 'shimmer 1.5s ease-in-out infinite',
11    }} />
12  );
13}
14
15function SkeletonCard() {
16  return (
17    <div style={{ background: '#0d1117', borderRadius: 8, padding: 16, border: '1px solid #333' }}>
18      <div style={{ height: 120, background: '#1a2744', borderRadius: 6, marginBottom: 12 }} />
19      <SkeletonLine width="70%" />
20      <div style={{ height: 8 }} />
21      <SkeletonLine width="90%" />
22      <div style={{ height: 8 }} />
23      <SkeletonLine width="50%" />
24    </div>
25  );
26}
27
28// Vraie carte produit
29function ProductCard({ name, price, desc }: { name: string; price: string; desc: string }) {
30  return (
31    <div style={{ background: '#1a2744', borderRadius: 8, padding: 16, border: '1px solid #64ffda' }}>
32      <div style={{
33        height: 120, background: 'linear-gradient(135deg, #0d47a1, #1a237e)',
34        borderRadius: 6, marginBottom: 12, display: 'flex', alignItems: 'center',
35        justifyContent: 'center', fontSize: 32
36      }}>
37        {name === 'Quantum Shield' ? '◆' : name === 'Neural Link' ? '◆' : '↯'}
38      </div>
39      <h3 style={{ color: '#64ffda', margin: 0 }}>{name}</h3>
40      <p style={{ color: '#aaa', margin: '8px 0', fontSize: 13 }}>{desc}</p>
41      <div style={{ color: '#4caf50', fontWeight: 'bold' }}>{price}</div>
42    </div>
43  );
44}
45
46const products = [
47  { name: 'Quantum Shield', price: '2500 QC', desc: 'Protection quantique nouvelle génération' },
48  { name: 'Neural Link', price: '4200 QC', desc: 'Connexion directe au réseau' },
49  { name: 'Flux Capacitor', price: '8800 QC', desc: 'Voyage dans le temps - version bêta' },
50];
51
52export default function SkeletonDemo() {
53  const [loaded, setLoaded] = useState(false);
54  const [loading, setLoading] = useState(false);
55
56  const simulateLoad = () => {
57    setLoaded(false);
58    setLoading(true);
59    setTimeout(() => { setLoading(false); setLoaded(true); }, 2500);
60  };
61
62  useEffect(() => { simulateLoad(); }, []);
63
64  return (
65    <div style={{ background: '#0f0f23', minHeight: '100vh', padding: 20, color: '#e0e0e0', fontFamily: 'monospace' }}>
66      <style>{`@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }`}</style>
67      <h1 style={{ color: '#64ffda' }}>Skeleton Loading States</h1>
68      <p style={{ color: '#888' }}>Les composants squelettes améliorent la vitesse de chargement perçue</p>
69      <button onClick={simulateLoad} style={{
70        background: '#2196f3', color: '#fff', border: 'none', borderRadius: 6,
71        padding: '8px 16px', cursor: 'pointer', margin: '12px 0', fontFamily: 'monospace'
72      }}>
73        Recharger les données
74      </button>
75      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
76        {loading
77          ? [0, 1, 2].map(i => <SkeletonCard key={i} />)
78          : loaded
79            ? products.map(p => <ProductCard key={p.name} {...p} />)
80            : null}
81      </div>
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. Les Client Components dans l'App Router sont marqués par :

  2. 2. La revalidation dans Next.js sert à :

Tâches pratiques dans le jeu

  • Éditeur de code

    Crée un Client Component avec useState et des gestionnaires d'événements pour une interface interactive

  • Tri vertical

    Place les éléments dans le bon ordre : <Suspense -> fallback={<Loading />}> -> <AsyncComponent /> -> </Suspense>

  • Clique dans l'ordre

    Reconstitue la syntaxe d'import du composant Suspense depuis React

  • Éditeur de code

    Crée une page produit avec ISR - revalidation des données toutes les 3600 secondes

Articles utiles