Aller au contenu
Lantorian

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

Chargement du profil
Élément suivant : se décale au chargement

Skeleton

Chargement du profil
Élément suivant : reste en place
Recharge la démo et observe l'encadré en pointillés : à gauche il saute, à droite il ne bouge pas.

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.

  1. 0 msNavigationLe navigateur demande /fr/invoices.
  2. 120 msCoquille + loading.tsxLayout, titre et skeletons affichés sans attendre les données.
  3. 550 msSuspense : statistiquesRequête rapide terminée, son skeleton est remplacé.
  4. 1300 msSuspense : tableauRequête lente terminée, le tableau apparaît sans décaler la page.
Animation ralentie deux fois. Chaque bloc arrive indépendamment : une requête lente ne bloque pas le reste de la page.

Quel outil pour quel chargement

SituationOutil
Navigation vers une nouvelle pageloading.tsx dans le dossier de la route
Un bloc lent dans une page rapideSuspense avec un skeleton en fallback
Envoi d'un formulaireuseFormStatus ou useActionState : bouton désactivé avec texte « Enregistrement… »
Filtre ou onglet côté clientuseTransition : 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.

src/features/invoices/components/invoice-table.skeleton.tsx
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>
  );
}

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.

src/app/[locale]/(app)/invoices/loading.tsx
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 />
    </>
  );
}

À éviter

loading.tsx
export default function Loading() {
  return (
    <div className="flex h-screen items-center justify-center">
      <Spinner />  {/* whole page blank, then everything jumps */}
    </div>
  );
}

À faire

loading.tsx
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).

src/components/forms/submit-button.tsx
'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-busy et un texte sr-only traduit (« 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.tsx propose de réessayer, l'état vide propose une action.