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

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 :
- Code redondant : chaque page répète cette gestion d’état
- États fragmentés : loading, data et error dans trois states distincts, source de bugs de synchronisation
- Obligation d’être un Client Component : avec useState et useEffect, tout tourne côté client — on perd les avantages du rendu serveur
- 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
-
Correspondre au layout réel : titre, extrait, tags — le skeleton doit refléter la même structure.
-
Animation discrète : un léger pulse suffit ; trop d’effets attire l’attention et allonge la attente perçue.
-
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 :
- Installez l’extension React DevTools
- Onglet Components
- Trouvez
<Suspense> - 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
-
loading.tsx — bonne pratique au niveau route : un fichier dans le dossier de route, Next.js s’occupe du reste. Fini le useState manuel.
-
Skeleton > spinner : structure visible, moins d’anxiété. CSS pur, react-loading-skeleton ou lib UI selon le projet.
-
Suspense en haut de l’arbre : barrière sur les opérations async en dessous ; mauvaise position = inefficace.
-
Routes dynamiques : n’oubliez pas key :
<Suspense key={params.id}>pour forcer le rechargement. -
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 :
- Documentation Next.js — loading.js
- Documentation Next.js — Loading UI et Streaming
- Documentation React — Suspense
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
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
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
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
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
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
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 ?
Quand loading.tsx s'affiche-t-il ?
Quelle différence entre Suspense et loading.tsx ?
Comment implémenter un skeleton screen ?
Comment gérer le loading sur une route dynamique ?
Peut-on personnaliser le style du loading ?
loading.tsx dégrade-t-il les performances ?
12 min de lecture · Publié le: 5 janv. 2026 · Mis à jour le: 27 juil. 2026
Guide complet Next.js
Si vous arrivez depuis la recherche, le plus rapide est de passer à l’article précédent ou suivant de cette série.
Précédent
Configuration Next.js : ESLint + Prettier + Husky en une fois
Votre PR rejetée un vendredi soir pour le formatage ? Conflits inutiles à cause de styles divergents ? Guide pas à pas pour configurer ESLint, Prettier et Husky : contrôles et formatage automatiques pour une collaboration plus fluide.
Partie 31 sur 51
Suivant
Pages d'erreur Next.js 404 et 500 : guide complet de l'implémentation au design
Personnalisez vos pages d'erreur Next.js avec not-found.tsx, error.tsx et global-error.tsx : exemples de code complets, bonnes pratiques de design et solutions aux problèmes courants pour améliorer l'UX et réduire le taux de rebond.
Partie 33 sur 51



Commentaires
Connectez-vous avec GitHub pour laisser un commentaire