Next.js8 min de lecture
Chargements et skeletons
Pendant qu'une donnée charge, on affiche sa silhouette, pas un spinner. L'utilisateur voit la page se construire, et rien ne saute quand le contenu arrive.
Pourquoi un skeleton
Un spinner ne dit rien de ce qui arrive et provoque un saut de mise en page (CLS, un des Core Web Vitals) quand le contenu le remplace. Un skeleton occupe déjà la bonne place. Regarde la boîte en pointillés sous chaque carte au rechargement.
Spinner
Skeleton
Ce qui se passe pendant le chargement
Avec l'App Router, le serveur envoie la page en flux : la coquille et les skeletons partent tout de suite, puis chaque bloc enveloppé dans Suspense remplace son skeleton dès que ses données sont prêtes.
- 0 msNavigationLe navigateur demande /fr/invoices.
- 120 msCoquille + loading.tsxLayout, titre et skeletons affichés sans attendre les données.
- 550 msSuspense : statistiquesRequête rapide terminée, son skeleton est remplacé.
- 1300 msSuspense : tableauRequête lente terminée, le tableau apparaît sans décaler la page.
Quel outil pour quel chargement
| Situation | Outil |
|---|---|
| Navigation vers une nouvelle page | loading.tsx dans le dossier de la route |
| Un bloc lent dans une page rapide | Suspense avec un skeleton en fallback |
| Envoi d'un formulaire | useFormStatus ou useActionState : bouton désactivé avec texte « Enregistrement… » |
| Filtre ou onglet côté client | useTransition : on garde l'ancien contenu atténué, avec aria-busy |
Le skeleton jumeau
Chaque composant qui charge des données a un fichier .skeleton.tsx à côté de lui, avec la même structure et les mêmes hauteurs. Les textes fixes (en-têtes de colonnes) sont affichés pour de vrai, seules les données sont remplacées.
import { useTranslations } from 'next-intl';
import { Skeleton } from '@/components/ui/skeleton';
export function InvoiceTableSkeleton({ rows = 8 }: { rows?: number }) {
const t = useTranslations('invoices.table');
return (
<div role="status" aria-busy="true">
<span className="sr-only">{t('loading')}</span>
<table className="w-full">
<thead>
<tr>
<th>{t('number')}</th>
<th>{t('customer')}</th>
<th className="text-right">{t('total')}</th>
</tr>
</thead>
<tbody>
{Array.from({ length: rows }, (_, i) => (
<tr key={i} className="h-12 border-t">
<td><Skeleton className="h-4 w-24" /></td>
<td><Skeleton className="h-4 w-40" /></td>
<td><Skeleton className="ml-auto h-4 w-16" /></td>
</tr>
))}
</tbody>
</table>
</div>
);
}import { getTranslations } from 'next-intl/server';
import { getInvoices } from '../queries';
export async function InvoiceTable({ status, page }: { status?: string; page: number }) {
const [t, invoices] = await Promise.all([getTranslations('invoices.table'), getInvoices({ status, page })]);
if (invoices.data.length === 0) return <EmptyInvoices />;
return (
<table className="w-full">
<thead>
<tr>
<th>{t('number')}</th>
<th>{t('customer')}</th>
<th className="text-right">{t('total')}</th>
</tr>
</thead>
<tbody>
{invoices.data.map((invoice) => (
<tr key={invoice.id} className="h-12 border-t">
<td>{invoice.number}</td>
<td>{invoice.customer.name}</td>
<td className="text-right">{invoice.totalFormatted}</td>
</tr>
))}
</tbody>
</table>
);
}import { cn } from '@/lib/utils';
// components/ui/skeleton.tsx (shadcn/ui)
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="skeleton"
className={cn('bg-accent animate-pulse rounded-md motion-reduce:animate-none', className)}
{...props}
/>
);
}
export { Skeleton };loading.tsx et Suspense
loading.tsx couvre toute la page pendant la navigation. Pour un chargement plus fin, on enveloppe chaque bloc lent dans son propre Suspense. Ajoute une key quand les paramètres de recherche changent.
import { InvoiceTableSkeleton } from '@/features/invoices/components/invoice-table.skeleton';
import { PageHeaderSkeleton } from '@/components/layout/page-header.skeleton';
// app/[locale]/(app)/invoices/loading.tsx
// Shown instantly on navigation, before the page's data is ready
export default function Loading() {
return (
<>
<PageHeaderSkeleton />
<InvoiceTableSkeleton />
</>
);
}export default async function DashboardPage() {
const t = await getTranslations('dashboard');
return (
<>
<h1>{t('title')}</h1> {/* static: renders immediately */}
<Suspense fallback={<StatsSkeleton />}>
<Stats /> {/* fast query, ~200 ms */}
</Suspense>
<Suspense fallback={<InvoiceTableSkeleton rows={5} />}>
<LatestInvoices /> {/* slow query, streams in later */}
</Suspense>
</>
);
}// Without a key, changing ?status= keeps the old table visible while loading.
// With a key, React shows the skeleton again for the new filter.
<Suspense key={`${status}-${page}`} fallback={<InvoiceTableSkeleton />}>
<InvoiceTable status={status} page={page} />
</Suspense>À éviter
export default function Loading() {
return (
<div className="flex h-screen items-center justify-center">
<Spinner /> {/* whole page blank, then everything jumps */}
</div>
);
}À faire
export default function Loading() {
return (
<>
<PageHeaderSkeleton /> {/* same height as the header */}
<InvoiceTableSkeleton rows={8} /> {/* same rows as the table */}
</>
);
}Les actions en cours
Pour une mutation, pas de skeleton : on garde le formulaire et on indique l'état dans le bouton. Le libellé vient du JSON (common.actions.saving).
'use client';
import { useTranslations } from 'next-intl';
import { useFormStatus } from 'react-dom';
import { Button } from '@/components/ui/button';
export function SubmitButton() {
const t = useTranslations('common.actions');
const { pending } = useFormStatus();
return (
<Button type="submit" disabled={pending} aria-busy={pending}>
{pending ? t('saving') : t('save')}
</Button>
);
}Règles
- Même boîte : le skeleton a la hauteur et la grille du contenu final. Aucun saut quand les données arrivent.
- Accessible :
role="status",aria-busyet un textesr-onlytraduit (« Chargement des factures »). - Mouvement réduit : l'animation de pulsation est coupée avec
motion-reduce:animate-none. - Pas de skeleton pour moins de 300 ms dans un composant client : un flash gris est pire qu'une attente courte.
- États vides et erreurs ont aussi leur design :
error.tsxpropose de réessayer, l'état vide propose une action.