Cours Next.js · Module 5: Stratégies de rendu
Skeletons de chargement et indicateurs de progression
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 :
- 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
- 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
- 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
- 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 :
- Skeletons de chargement (Skeleton UI) - représentations graphiques simplifiées du contenu qui sera affiché
- Indicateurs de progression - représentations visuelles de l'avancement du chargement (barres, cercles)
- Loaders indéterminés - indicateurs animés (par ex. cercles qui tournent) qui n'affichent pas de progression précise
- Rendu différé - afficher les indicateurs de chargement seulement après un certain temps pour éviter le scintillement
- 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 :
Adapte-les au contenu - les skeletons de chargement doivent refléter la forme et les proportions du contenu réel
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)
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
Minimise le CLS - définis toujours les dimensions et proportions des skeletons de chargement
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
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
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. Les Client Components dans l'App Router sont marqués par :
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