Changer le thème

Gestion des états de chargement Next.js : guide pratique de loading.tsx et Suspense

Easton editorial illustration: hydration gauge console

Vous avez peut-être déjà vécu ça : l’utilisateur clique sur un lien, puis l’écran reste blanc pendant trois bonnes secondes, sans aucun retour. Il se demande : « Est-ce que ça a planté ? » Il appuie frénétiquement sur F5 pour rafraîchir, et la page venait juste de se charger…

Avant, je gérais le loading exactement comme ça. À chaque nouvelle page, j’ajoutais dans le composant :

const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);

useEffect(() => {
  setLoading(true);
  fetchData()
    .then(setData)
    .finally(() => setLoading(false));
}, []);

if (loading) return <Spinner />;

Le code est verbeux, et il faut le répéter sur chaque page. Pire encore : chacun dans l’équipe écrit sa propre logique de chargement — état global, Context, etc. — et la maintenance devient un cauchemar.

Un jour, en parcourant la documentation officielle de Next.js, j’ai découvert que Next.js intègre déjà une solution plus élégante pour gérer le loading : loading.tsx et Suspense.

Depuis, j’ai compris que gérer les états de chargement pouvait être bien plus simple. Moins de code, et une meilleure expérience utilisateur. Cet article partage ce que j’ai appris en pratique.

Pourquoi utiliser loading.tsx et Suspense

Les limites de l’approche traditionnelle

Voici un exemple concret. Supposons une page de liste d’articles de blog, écrite à l’ancienne :

// app/blog/page.tsx
'use client';
import { useState, useEffect } from 'react';

export default function BlogPage() {
  const [loading, setLoading] = useState(true);
  const [posts, setPosts] = useState([]);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch('/api/posts')
      .then(res => res.json())
      .then(data => {
        setPosts(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, []);

  if (loading) {
    return <div className="spinner">Loading...</div>;
  }

  if (error) {
    return <div>Error: {error.message}</div>;
  }

  return (
    <div>
      {posts.map(post => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.excerpt}</p>
        </article>
      ))}
    </div>
  );
}

Ça a l’air correct, non ? Sauf que :

  1. Code redondant : chaque page répète cette gestion d’état
  2. États fragmentés : loading, data et error dans trois states distincts, source de bugs de synchronisation
  3. Obligation d’être un Client Component : avec useState et useEffect, tout tourne côté client — on perd les avantages du rendu serveur
  4. Mauvaise UX : entre le clic et l’affichage du loading, un blanc visible apparaît

En code review, on voit toutes les variantes : Context, Zustand, ou chaque composant pour soi. Sur un gros projet, c’est ingérable.

La solution Next.js

L’App Router de Next.js propose trois piliers :

1. loading.tsx — convention plutôt que configuration

Créez un fichier loading.tsx dans le dossier de route : Next.js l’utilise automatement comme UI de chargement. Pas de useState, pas de Suspense à envelopper vous-même.

2. Suspense — natif à React 18

Suspense permet un contrôle fin au niveau composant. La partie lente est isolée ; le reste s’affiche sans attendre toute la page.

3. Streaming — affichage progressif

Avec le rendu en streaming de Next.js, la page arrive par morceaux : en-tête, barre latérale, puis les données lentes. Fini l’écran blanc.

Avec skeleton screens et streaming, on réduit nettement le FCP (First Contentful Paint) et le LCP (Largest Contentful Paint) — quelques points de plus sur PageSpeed Insights.

loading.tsx — bases

Premiers pas : votre premier loading.tsx

Passons directement au plus simple.

Structure de dossiers :

app/
  blog/
    page.tsx

Ajoutez loading.tsx dans blog :

app/
  blog/
    loading.tsx  ← nouveau
    page.tsx

Contenu minimal :

// app/blog/loading.tsx
export default function Loading() {
  return (
    <div className="flex items-center justify-center min-h-screen">
      <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
      <p className="ml-4">Chargement...</p>
    </div>
  );
}

Dix lignes suffisent. Lors de la visite de /blog, Next.js affiche ce composant tant que page.tsx n’est pas prêt.

Point clé : pas besoin d’envelopper Suspense manuellement — Next.js enveloppe votre page.tsx dans <Suspense fallback={<Loading />}>.

La première fois, j’étais sceptique : « Trop simple pour être vrai. » Ça marche. Et le code est bien plus propre.

Portée de loading.tsx

Concept important : le segment de route (Route Segment). loading.tsx s’applique au page.tsx du même dossier et à toutes les sous-routes.

Exemple :

app/
  blog/
    loading.tsx     ← s'applique à /blog et /blog/[id]
    page.tsx        ← liste /blog
    [id]/
      page.tsx      ← détail /blog/123

Affichage dans ces cas :

  • navigation vers /blog (chargement de la liste)
  • clic de la liste vers /blog/123 (chargement du détail)

Mais le layout n’est pas affecté. Si blog/layout.tsx contient une barre de navigation, elle reste visible ; seule la zone page.tsx est remplacée par le loading.

C’est ce que la doc appelle « layouts partagés restent interactifs » : pendant le chargement, l’utilisateur peut encore naviguer ailleurs.

Schéma :

Layout (toujours visible)
  ├─ Barre de navigation
  └─ Limite Suspense
       ├─ UI de chargement (pendant le fetch)
       └─ Page (une fois les données prêtes)

Server Component vs Client Component

loading.tsx est par défaut un Server Component. Souvent, retourner du JSX suffit.

Pour des animations (Framer Motion, etc.), ajoutez 'use client' :

// app/blog/loading.tsx
'use client';
import { motion } from 'framer-motion';

export default function Loading() {
  return (
    <motion.div
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      className="flex items-center justify-center min-h-screen"
    >
      <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
    </motion.div>
  );
}

Règle personnelle : Server Component par défaut ; 'use client' seulement si nécessaire — moins de JavaScript côté client, chargement plus rapide.

Skeleton screens en pratique

Pourquoi un skeleton vaut mieux qu’un spinner

Les spinners tournent partout, mais un skeleton screen (écran squelette) offre une bien meilleure UX.

En psychologie utilisateur : voir un skeleton fait anticiper « le contenu arrive », la attente perçue est plus courte. Un spinner dit seulement « chargement en cours », sans forme ni durée — plus d’anxiété.

Le skeleton annonce aussi la structure : trois barres horizontales suggèrent trois articles. L’utilisateur sait à quoi s’attendre.

Trois approches d’implémentation

Approche 1 : CSS pur (le plus léger)

Sans dépendance supplémentaire :

// app/blog/loading.tsx
export default function Loading() {
  return (
    <div className="max-w-4xl mx-auto p-6">
      {[1, 2, 3].map((i) => (
        <div key={i} className="mb-8 animate-pulse">
          {/* Squelette titre */}
          <div className="h-8 bg-gray-200 rounded w-3/4 mb-4"></div>
          {/* Squelette extrait */}
          <div className="space-y-2">
            <div className="h-4 bg-gray-200 rounded"></div>
            <div className="h-4 bg-gray-200 rounded w-5/6"></div>
          </div>
          {/* Squelette métadonnées */}
          <div className="flex gap-4 mt-4">
            <div className="h-3 bg-gray-200 rounded w-20"></div>
            <div className="h-3 bg-gray-200 rounded w-24"></div>
          </div>
        </div>
      ))}
    </div>
  );
}

Zéro dépendance, meilleures perfs ; un peu plus de CSS à écrire.

Approche 2 : react-loading-skeleton (le plus rapide)

npm install react-loading-skeleton
// app/blog/loading.tsx
'use client';
import Skeleton from 'react-loading-skeleton';
import 'react-loading-skeleton/dist/skeleton.css';

export default function Loading() {
  return (
    <div className="max-w-4xl mx-auto p-6">
      {[1, 2, 3].map((i) => (
        <div key={i} className="mb-8">
          <Skeleton height={32} width="75%" className="mb-4" />
          <Skeleton count={2} />
          <div className="flex gap-4 mt-4">
            <Skeleton width={80} />
            <Skeleton width={100} />
          </div>
        </div>
      ))}
    </div>
  );
}

Pratique, bonnes animations — mon choix sur les petits projets.

Approche 3 : shadcn/ui (le plus professionnel)

Si vous utilisez déjà shadcn/ui :

npx shadcn-ui@latest add skeleton
// app/blog/loading.tsx
import { Skeleton } from '@/components/ui/skeleton';

export default function Loading() {
  return (
    <div className="max-w-4xl mx-auto p-6">
      {[1, 2, 3].map((i) => (
        <div key={i} className="mb-8">
          <Skeleton className="h-8 w-3/4 mb-4" />
          <Skeleton className="h-4 w-full mb-2" />
          <Skeleton className="h-4 w-5/6 mb-4" />
          <div className="flex gap-4">
            <Skeleton className="h-3 w-20" />
            <Skeleton className="h-3 w-24" />
          </div>
        </div>
      ))}
    </div>
  );
}

Styles alignés sur votre design system.

Principes de design des skeletons

  1. Correspondre au layout réel : titre, extrait, tags — le skeleton doit refléter la même structure.

  2. Animation discrète : un léger pulse suffit ; trop d’effets attire l’attention et allonge la attente perçue.

  3. Quantité raisonnable : 3 à 5 entrées suffisent ; remplir l’écran alourdit l’UI.

Cas complet : liste d’articles de blog

loading.tsx :

// app/blog/loading.tsx
export default function BlogLoading() {
  return (
    <div className="max-w-4xl mx-auto px-4 py-8">
      <div className="h-12 bg-gray-200 rounded w-1/3 mb-8 animate-pulse"></div>

      <div className="space-y-8">
        {[1, 2, 3].map((i) => (
          <article key={i} className="border-b pb-8 animate-pulse">
            <div className="h-8 bg-gray-200 rounded w-3/4 mb-3"></div>
            <div className="space-y-2 mb-4">
              <div className="h-4 bg-gray-200 rounded"></div>
              <div className="h-4 bg-gray-200 rounded w-11/12"></div>
              <div className="h-4 bg-gray-200 rounded w-4/5"></div>
            </div>
            <div className="flex gap-3">
              <div className="h-6 bg-gray-200 rounded-full w-16"></div>
              <div className="h-6 bg-gray-200 rounded-full w-20"></div>
            </div>
          </article>
        ))}
      </div>
    </div>
  );
}

page.tsx en Server Component :

// app/blog/page.tsx
async function getPosts() {
  const res = await fetch('https://api.example.com/posts', {
    cache: 'no-store'
  });

  if (!res.ok) throw new Error('Failed to fetch posts');

  return res.json();
}

export default async function BlogPage() {
  const posts = await getPosts();

  return (
    <div className="max-w-4xl mx-auto px-4 py-8">
      <h1 className="text-4xl font-bold mb-8">Articles de blog</h1>

      <div className="space-y-8">
        {posts.map((post) => (
          <article key={post.id} className="border-b pb-8">
            <h2 className="text-2xl font-semibold mb-3">
              <a href={`/blog/${post.slug}`} className="hover:text-blue-600">
                {post.title}
              </a>
            </h2>
            <p className="text-gray-600 mb-4">{post.excerpt}</p>
            <div className="flex gap-3">
              {post.tags.map((tag) => (
                <span key={tag} className="px-3 py-1 bg-gray-100 rounded-full text-sm">
                  {tag}
                </span>
              ))}
            </div>
          </article>
        ))}
      </div>
    </div>
  );
}

page.tsx devient une fonction async qui await les données — plus de useState ni useEffect. Tout s’exécute côté serveur, bundle client plus léger.

Astuce debug : React DevTools

En dev, le loading flash parfois trop vite pour être ajusté.

Avec React DevTools :

  1. Installez l’extension React DevTools
  2. Onglet Components
  3. Trouvez <Suspense>
  4. Clic droit → « Suspend this Suspense boundary »

Le loading reste affiché pour peaufiner les styles. Annulez le suspend une fois terminé.

J’aurais gagné du temps en connaissant cette option plus tôt.

Suspense — techniques avancées

Définir manuellement les limites Suspense

loading.tsx couvre la route entière ; parfois plusieurs sources de données doivent charger indépendamment.

Erreur fréquente : placer Suspense à l’intérieur du composant qui fetch :

// ❌ Mauvais exemple — Suspense trop bas
async function PostList() {
  const posts = await fetchPosts();

  return (
    <Suspense fallback={<Loading />}>  {/* ne fonctionne pas ! */}
      <div>
        {posts.map(post => <Post key={post.id} {...post} />)}
      </div>
    </Suspense>
  );
}

Suspense doit être plus haut dans l’arbre pour capturer les opérations asynchrones en dessous.

// ✅ Bon exemple — Suspense dans le parent
export default function BlogPage() {
  return (
    <div>
      <h1>Articles de blog</h1>

      <Suspense fallback={<PostListSkeleton />}>
        <PostList />
      </Suspense>
    </div>
  );
}

async function PostList() {
  const posts = await fetchPosts();

  return (
    <div>
      {posts.map(post => <Post key={post.id} {...post} />)}
    </div>
  );
}

Suspense est une barrière : tant qu’un composant en dessous attend des données, le fallback s’affiche.

Cas particulier des routes dynamiques

Piège classique : page produit /products/[id], passage du produit A (id=1) au produit B (id=2). loading.tsx ne s’affiche pas !

Le contenu bascule directement de A à B, sans transition.

React réutilise l’instance si le type de composant est identique — Suspense ne se réactive pas.

Solution : ajouter une prop key :

// app/products/[id]/page.tsx
import { Suspense } from 'react';

export default function ProductPage({ params }: { params: { id: string } }) {
  return (
    <Suspense key={params.id} fallback={<ProductSkeleton />}>
      <ProductDetail id={params.id} />
    </Suspense>
  );
}

async function ProductDetail({ id }: { id: string }) {
  const product = await fetchProduct(id);

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <span>${product.price}</span>
    </div>
  );
}

<Suspense key={params.id} ...> : à chaque changement d’id, nouvelle instance, nouvel état suspendu, loading visible.

Coordonner plusieurs états de chargement

Dashboard avec infos utilisateur, statistiques et activité récente — deux stratégies :

Stratégie 1 : tout charger avant d’afficher (un Suspense global)

export default function Dashboard() {
  return (
    <Suspense fallback={<DashboardSkeleton />}>
      <UserInfo />
      <Statistics />
      <RecentActivity />
    </Suspense>
  );
}

Simple, contenu complet d’un coup ; le plus lent retarde tout.

Stratégie 2 : affichage incrémental (plusieurs Suspense)

export default function Dashboard() {
  return (
    <div>
      <Suspense fallback={<UserInfoSkeleton />}>
        <UserInfo />
      </Suspense>

      <Suspense fallback={<StatsSkeleton />}>
        <Statistics />
      </Suspense>

      <Suspense fallback={<ActivitySkeleton />}>
        <RecentActivity />
      </Suspense>
    </div>
  );
}

Ce qui est prêt s’affiche en premier ; la page peut « sauter » au fur et à mesure.

Mon choix : selon l’importance des données.

  • Données core (profil utilisateur) : un Suspense commun
  • Données secondaires (recommandations, pubs) : Suspense séparés

Suspense ne fonctionne pas ?

1. Mode de récupération des données

Dans l’App Router :

  • ✅ await direct dans un Server Component (recommandé)
  • ✅ bibliothèques compatibles Suspense (SWR, React Query)
  • ❌ fetch dans useEffect
  • ❌ Promise.then classique

2. Position du composant

Suspense au-dessus du composant qui fetch, jamais au même niveau ou en dessous.

3. Versions

  • React 18+
  • Next.js 13+ (App Router)

4. Debug

React DevTools → suspendre manuellement la limite. Si rien ne change, Suspense n’est pas actif.

Piège de useFormStatus

Avec les Server Actions, useFormStatus affiche l’état de soumission.

useFormStatus ne fonctionne que dans un Client Component.

Le <form> doit être rendu côté serveur pour lier la Server Action.

Pattern : Server Component pour le form, Client Component pour le bouton.

// app/actions.ts
'use server';
export async function submitForm(formData: FormData) {
  await saveToDatabase(formData);
}
// app/page.tsx (Server Component)
import { submitForm } from './actions';
import { SubmitButton } from './submit-button';

export default function Page() {
  return (
    <form action={submitForm}>
      <input name="email" type="email" />
      <SubmitButton />
    </form>
  );
}
// app/submit-button.tsx (Client Component)
'use client';
import { useFormStatus } from 'react-dom';

export function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Envoi en cours...' : 'Envoyer'}
    </button>
  );
}

Impact du prefetch sur le loading

<Link> précharge la page cible quand le lien entre dans le viewport.

Parfois le loading disparaît aussitôt — les données sont déjà là.

Pour tester le loading :

<Link href="/blog" prefetch={false}>
  Blog
</Link>

En production, gardez le prefetch activé. Si le flash est trop court, imposez une durée minimale (~300 ms) ou préférez un skeleton au spinner.

Synthèse

  1. loading.tsx — bonne pratique au niveau route : un fichier dans le dossier de route, Next.js s’occupe du reste. Fini le useState manuel.

  2. Skeleton > spinner : structure visible, moins d’anxiété. CSS pur, react-loading-skeleton ou lib UI selon le projet.

  3. Suspense en haut de l’arbre : barrière sur les opérations async en dessous ; mauvaise position = inefficace.

  4. Routes dynamiques : n’oubliez pas key : <Suspense key={params.id}> pour forcer le rechargement.

  5. Plusieurs sources : Suspense séparés : core ensemble, secondaire en async.

Passer du useState manuel à loading.tsx, ce n’est pas plus de travail — c’est travailler plus intelligemment. Moins de code, moins de bugs, meilleure UX.

Prochaines étapes

Agir tout de suite : prenez une liste simple existante, migrez vers loading.tsx. Essayer vaut mieux que dix articles.

Aller plus loin : après le loading, explorez les Error Boundaries — même famille, l’un gère le chargement, l’autre les erreurs. Un article dédié suivra.

Partager : comment gérez-vous le loading dans vos projets ? Quels pièges avez-vous rencontrés ? Les commentaires sont ouverts.


Références :

Flux complet de gestion du loading Next.js

Obtenir une expérience de chargement professionnelle avec loading.tsx et Suspense, sans useState manuel

⏱️ Estimated time: 1 hr

  1. 1

    Step 1: Créer le fichier loading.tsx

    Créez loading.tsx dans le dossier de route :
    • app/dashboard/loading.tsx : état de chargement pour dashboard
    • app/products/[id]/loading.tsx : état pour route dynamique

    Contenu :
    export default function Loading() {
    return <div>Chargement...</div>
    }

    Next.js affiche automatiquement ce composant au chargement de la page
  2. 2

    Step 2: Implémenter un skeleton screen

    Créez une UI de chargement plus professionnelle :
    • Utilisez un composant Skeleton qui imite la mise en page
    • Gardez une structure proche du contenu réel
    • Ajoutez une animation discrète

    Exemple :
    export default function Loading() {
    return (
    <div className="animate-pulse">
    <div className="h-8 bg-gray-200 rounded w-3/4 mb-4"></div>
    <div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
    <div className="h-4 bg-gray-200 rounded w-5/6"></div>
    </div>
    )
    }
  3. 3

    Step 3: Envelopper les composants async avec Suspense

    Dans vos composants :
    • Enveloppez ceux qui récupèrent des données de façon asynchrone
    • Définissez un fallback pour l'état de chargement
    • Suspense imbriqués pour un contrôle fin

    Exemple :
    <Suspense fallback={<Loading />}>
    <AsyncComponent />
    </Suspense>

    Plusieurs composants peuvent avoir chacun leur Suspense :
    • Chargement indépendant
    • Le plus rapide s'affiche en premier
    • Meilleure expérience utilisateur
  4. 4

    Step 4: Gérer le loading des routes dynamiques

    Loading sur route dynamique :
    • Créez loading.tsx dans le dossier de la route dynamique
    • Next.js gère le chargement lors du changement de paramètres
    • Pas de gestion manuelle du loading

    Exemple :
    app/products/[id]/
    ├── loading.tsx # affiché automatiquement au changement de paramètre
    └── page.tsx

    De /products/1 vers /products/2,
    loading.tsx s'affiche automatiquement
  5. 5

    Step 5: Optimiser l'expérience de chargement

    Bonnes pratiques :
    • Préférez un skeleton à un simple spinner
    • Alignez l'UI de chargement sur le contenu final
    • Animation discrète (animate-pulse)
    • Suspense pour le streaming

    À éviter :
    • loading.tsx partout sans réfléchir
    • UI de chargement trop complexe
    • Oublier la gestion d'erreurs (error.tsx)
  6. 6

    Step 6: Tester et valider

    Points de test :
    • État de chargement lors de la navigation
    • Changement de paramètres sur route dynamique
    • Expérience sur réseau lent
    • Fluidité de l'UI de chargement

    Checklist :
    • Chaque route a un loading adapté
    • UI alignée sur le contenu réel
    • Pas de flash ni de décalage de layout
    • Expérience fluide

FAQ

Quelle différence entre loading.tsx et useState manuel ?
loading.tsx est une convention Next.js : affichage automatique au chargement, sans gestion d'état manuelle. useState exige de répéter le code sur chaque page, avec risque d'erreurs. loading.tsx réduit le code d'environ 50 %, améliore l'UX et supporte le streaming.
Quand loading.tsx s'affiche-t-il ?
Automatiquement dans ces cas : 1) navigation vers la route ; 2) changement de paramètre sur route dynamique ; 3) chargement d'une route parente. Next.js gère le timing ; loading.tsx disparaît une fois les données prêtes.
Quelle différence entre Suspense et loading.tsx ?
loading.tsx couvre toute la route. Suspense agit au niveau composant, pour un contrôle plus fin. Les deux coexistent : loading.tsx pour la route, Suspense à l'intérieur pour des parties spécifiques.
Comment implémenter un skeleton screen ?
Dans loading.tsx, utilisez un composant Skeleton qui imite la mise en page. Tailwind animate-pulse pour l'animation. Gardez la même structure que le contenu final pour éviter les décalages de layout.
Comment gérer le loading sur une route dynamique ?
Créez loading.tsx dans le dossier dynamique, ex. app/products/[id]/loading.tsx. De /products/1 à /products/2, Next.js affiche loading.tsx sans code supplémentaire.
Peut-on personnaliser le style du loading ?
Oui. loading.tsx est un composant React normal : Tailwind, CSS Modules, styled-components, etc. Préférez un skeleton à un simple spinner pour une meilleure UX.
loading.tsx dégrade-t-il les performances ?
Non — au contraire. Il supporte le streaming : la page se charge par morceaux, les parties rapides s'affichent en premier. Meilleure expérience globale.

12 min de lecture · Publié le: 5 janv. 2026 · Mis à jour le: 27 juil. 2026

Commentaires

Connectez-vous avec GitHub pour laisser un commentaire

Easton BlogEaston Blog