Chargement, attente, échec : comment les limites de paresse, de suspense et d’erreur s’intègrent
Comprendre comment React.lazy, Suspense et les limites d’erreur divisent le code, affichent des solutions de remplacement et empêchent les pannes, ainsi que l’endroit où placer chaque limite pour améliorer l’expérience utilisateur.
Sources: React Suspense, Lazy Loading & Error Boundaries — A Practical Guide
Une page React peut se trouver dans trois états problématiques : son code n’a pas été téléchargé, son contenu n’est pas prêt, ou le rendu a échoué. React.lazy diffère le chargement du code, Suspense affiche un élément de remplacement tant que quelque chose est en attente, et une limite d’erreur remplace un sous-arbre défaillant par une solution de secours. Une fois que vous comprenez comment ils fonctionnent ensemble, vous pouvez diviser les bundles de manière judicieuse et placer des limites qui permettent au reste de la page de rester utilisable.
Déférer le chargement du code avec React.lazy
Le chargement différé signifie que le code d’un composant n’est téléchargé que lorsque React a besoin de le rendre pour la première fois, et non comme partie du bundle initial. La différence est visible dans l’import : une instruction import Dashboard from "./Dashboard" statique charge immédiatement le module, tandis que l’enveloppement d’un import dynamique dans lazy() permet au bundler d’émettre un chunk séparé. (Dans cet extrait, le deuxième import est présenté uniquement à des fins de comparaison.)
import { lazy } from "react";
const Dashboard = lazy(() => import("./Dashboard"));
Instead of:
import Dashboard from "./Dashboard";
Avantages du chargement différé
Imaginez une application composée de plusieurs zones distinctes :
Application
├── Home
├── Products
├── Dashboard
├── Analytics
├── Admin
└── Payment
Une personne qui se rend directement sur la page d’accueil n’a pas encore besoin de ces éléments :
Analytics
Admin
Payment
Leur téléchargement malgré tout augmente la taille du fichier initial. Avec le chargement différé, les autres chunks sont téléchargés sur demande :
Initial Load
↓
Home + common code
↓
User opens Dashboard
↓
Download Dashboard chunk
↓
Render Dashboard
Fonctionnement interne du chargement différé
lazy() accepte une fonction qui retourne une promesse, généralement un import() dynamique. Le module cible doit disposer d’une exportation par défaut qui correspond à un composant. La séquence est la suivante (le diagramme est conceptuel, pas du code) :
const Dashboard = lazy(() => import("./Dashboard"));
Conceptually:
lazy()
↓
dynamic import()
↓
JavaScript chunk requested
↓
Promise pending
↓
Component becomes available
↓
React renders component
Deux règles pratiques s’imposent. Déclarez les composants lazy au niveau le plus élevé du module, jamais à l’intérieur d’un autre composant, sinon chaque rendu créera un nouveau type de composant et réinitialisera son état. Et comme le chunk arrive de manière asynchrone, il faut bien que quelque chose décide ce que l’utilisateur voit en attendant. C’est là la fonction du Suspense.
Afficher un élément de remplacement avec Suspense
Suspense affiche un élément de remplacement tant que tout composant descendant est en attente, c’est-à-dire qu’il ne peut pas encore être rendu. Enveloppez le composant lazy et passez l’élément de remplacement :
import { lazy, Suspense } from "react";
const Dashboard = lazy(() => import("./Dashboard"));
function App() {
return (
<Suspense fallback={<p>Loading dashboard...</p>}>
<Dashboard />
</Suspense>
);
}
Pendant que le morceau de données est téléchargé, React se rend jusqu’à la limite la plus proche et affiche son contenu de remplacement :
Dashboard
↓
Not ready
↓
Suspense
↓
"Loading dashboard..."
Lorsque la promesse est résolue, React tente à nouveau l’affichage et le contenu réel remplace celui de remplacement :
Dashboard loaded
↓
React retries rendering
↓
Dashboard displayed
Suspense ne gère pas le chargement ; c’est lazy() (ou une bibliothèque de données prenant en charge Suspense) qui déclenche ce processus. Suspense se contente de marquer l’endroit où un état d’attente peut apparaître et à quoi il ressemble.
Utilisation conjointe de lazy et Suspense
La plupart des codes réels combinent ces deux éléments, en utilisant généralement un contenu de remplacement plus riche qu’une simple ligne de texte :
const Dashboard = lazy(() => import("./Dashboard"));
function App() {
return (
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard />
</Suspense>
);
}
Le cycle de vie complet lors de la première visite, encore une fois sous forme de diagramme :
User opens Dashboard
↓
React tries to render Dashboard
↓
Dashboard chunk isn't available
↓
Dynamic import starts
↓
Suspense fallback shown
↓
Dashboard chunk loads
↓
React retries rendering
↓
Dashboard displayed
Squelettes plutôt que tournevis
<Suspense fallback={<p>Loading...</p>}>
Un composant squelette qui imite le layout final est généralement un meilleur choix :
<Suspense fallback={<DashboardSkeleton />}>
Il réserve approximativement l’espace nécessaire, réduisant les décalages de layout et rendant l’attente plus courte :
┌─────────────────────────────┐
│ Dashboard │
├─────────────────────────────┤
│ ░░░░░░░ ░░░░░░░ │
│ ░░░░░░░ ░░░░░░░ │
│ │
│ ░░░░░░░░░░░░░░░░░░ │
└─────────────────────────────┘
Enchaînement de limites Suspense
Avoir une seule limite pour toute l’application n’est que rarement souhaitable. Les limites peuvent être imbriquées, et chaque composant en attente est géré par la limite la plus proche au-dessus de lui :
<Suspense fallback={<PageSkeleton />}>
<Header />
<Suspense fallback={<ProductsSkeleton />}>
<Products />
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations />
</Suspense>
</Suspense>
Plutôt que que toute la page se transforme en un seul message de chargement,
Entire page → Loading...
chaque section peut s’afficher indépendamment :
Header ✅
Products Loading...
[Product Skeleton]
Recommendations Loading...
[Recommendation Skeleton]
Gestion des erreurs avec les Error Boundaries
L’attente est un résultat possible ; l’échec en est un autre. Supposons qu’un composant lance une erreur pendant le rendu :
function Dashboard() {
throw new Error("Dashboard crashed!");
}
Une erreur de rendu non capturée démonte toute la racine React, laissant une page vide. Une limite d’erreur est un composant qui intercepte les erreurs générées par ses descendants et affiche une interface de secours à la place :
Dashboard
↓
Rendering Error
↓
Error Boundary
↓
Fallback UI
Utiliser une limite d’erreur revient à enrouler n’importe quel autre composant, et l’utilisateur voit alors un message « Quelque chose a mal tourné » au lieu d’une écran vide :
<ErrorBoundary>
<Dashboard />
</ErrorBoundary>
Rédiger une limite d’erreur
Les limites d’erreur restent des composants de classe ; il n’existe pas d’équivalent basé sur des hooks dans React lui-même. Deux méthodes sont importantes. La méthode statique getDerivedStateFromError s’exécute pendant le rendu et retourne un état qui passe à l’interface de secours. componentDidCatch s’exécute par la suite avec l’erreur et info.componentStack, qui sert à enregistrer des logs ou à envoyer des rapports.
import React from "react";
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = {
hasError: false,
};
}
static getDerivedStateFromError() {
return {
hasError: true,
};
}
componentDidCatch(error, info) {
console.error(error);
console.error(info.componentStack);
}
render() {
if (this.state.hasError) {
return <h2>Something went wrong.</h2>;
}
return this.props.children;
}
}
Elle est utilisée exactement comme dans l’exemple précédent :
<ErrorBoundary>
<Dashboard />
</ErrorBoundary>
Cette version minimale ne dispose d’aucun mécanisme de réinitialisation ; un bouton « Essayer à nouveau » doit remettre hasError à la valeur false.
Comment React trouve la frontière
On définit une frontière autour d’un composant de profil,
<ErrorBoundary>
<Profile />
</ErrorBoundary>
où le composant lance une erreur :
function Profile() {
throw new Error("Profile failed");
}
React affiche le contenu du tree jusqu’à ce qu’une erreur se produise :
ErrorBoundary
↓
Profile
↓
💥 Error
Il cherche ensuite vers le haut la frontière d’erreur la plus proche :
Profile
↑
ErrorBoundary
Cette frontière passe en état d’erreur,
{
hasError: true
}
et affiche le contenu de secours. Une frontière ne peut pas capturer ses propres erreurs, seulement celles de ses enfants ; une erreur à l’intérieur du contenu de secours se propage vers la frontière suivante.
Erreurs que capture une frontière
Les limites gèrent les erreurs survenues pendant le rendu de React, dans les méthodes de cycle de vie et dans les constructeurs des composants descendants. Un cas classique est la lecture d’une propriété égale à undefined lors du rendu :
function Profile() {
const user = undefined;
return <h1>{user.name}</h1>;
}
Elles capturent également les importations différées qui échouent. Si un chunk ne parvient pas à être téléchargé, par exemple après qu’un déploiement a supprimé le fichier ancien, l’erreur apparaît dans la limite d’erreur la plus proche, et non dans Suspense.
Erreurs que les limites ne détectent pas
C’est une question fréquente lors des entretiens : les limites ne voient pas les erreurs dans les gestionnaires d’événements, car ceux-ci s’exécutent en dehors du processus de rendu.
function Button() {
const handleClick = () => {
throw new Error("Click failed");
};
return (
<button onClick={handleClick}>
Click
</button>
);
}
Gérez-les là où elles se produisent, en utilisant une gestion d’erreurs ordinaire dans le gestionnaire correspondant :
try {
// operation
} catch (error) {
// handle error
}
Il en va de même pour les appels asynchrones tels que les temporisateurs ou les chaînes de promesses qui génèrent des erreurs après la fin du rendu :
setTimeout(() => {
throw new Error("Async error");
}, 1000);
Si une telle erreur devait afficher le mécanisme de secours du boundary, capturez-la et stockez-la dans l’état, puis lancez-la lors du prochain rendu.
Combiner les trois éléments
Un modèle de production enrobe Suspense à l’intérieur d’un Error Boundary :
<ErrorBoundary>
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard />
</Suspense>
</ErrorBoundary>
Avec le tableau de bord défini de manière différée,
const Dashboard = lazy(() => import("./Dashboard"));
le flux combiné couvre tous les scénarios possibles. Interprétez la première branche comme « code manquant ? » : dans ce cas, React commence l’importation et affiche le squelette ; par la suite, le rendu réussit ou aboutit au Error Boundary.
Dashboard
│
▼
Need the code?
│
┌────────┴────────┐
│ │
YES NO
│ │
Render normally dynamic import()
│
Pending
│
▼
Suspense
│
▼
Skeleton UI
│
Loaded
│
▼
Render again
│
┌───────┴───────┐
│ │
Success Error
│ │
▼ ▼
Dashboard Error Boundary
│
▼
Error UI
Séparation par route
Les routes constituent les points de séparation les plus naturels, car chaque page est une interface autonome. Déclarez chaque page de manière différée :
const Home = lazy(() => import("./pages/Home"));
const Products = lazy(() => import("./pages/Products"));
const Dashboard = lazy(() => import("./pages/Dashboard"));
ensuite, enrobez le routeur dans un boundary afin que la navigation vers une page non chargée affiche un chargeur :
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products" element={<Products />} />
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</Suspense>
Savoir ce qu’il ne faut pas séparer
Le chargement différé d’un composant minimal est contre-productif :
const Button = lazy(() => import("./Button"));
Chaque composant chargé différément entraîne une requête et un état de chargement, que un bouton ne compense jamais. Les cas appropriés sont les grands tableaux de bord, les sections d’analyse et d’administration, les éditeurs de texte enrichi, les cartes, les graphiques complexes ou les bibliothèques tierces, les flux de paiement ainsi que les routes peu fréquentées. Il s’agit de séparer le code coûteux ou rarement utilisé, et non tout le code.
Positionnement intentionnel des limites
Le placement du suspense détermine ce qui disparaît
Une limite située en hauteur dans la hiérarchie signifie que tout composant lent remplace une grande zone par son contenu de fallback :
<Suspense fallback={<FullPageLoader />}>
<EntireApplication />
</Suspense>
En la plaçant autour de la partie lente, on garde le en-tête et le pied de page visibles et utilisables pendant le chargement :
<Header />
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard />
</Suspense>
<Footer />
Le placement de la limite d’erreur détermine ce qui survit
La même logique s’applique aux pannes. Une seule frontière au niveau racine transforme tout erreur en une page d’erreur complète :
<ErrorBoundary>
<EntireApplication />
</ErrorBoundary>
En encadrant des sections indépendantes, on limite l’impact d’une panne à un seul endroit :
<App>
<Header />
<ErrorBoundary>
<Dashboard />
</ErrorBoundary>
<ErrorBoundary>
<Recommendations />
</ErrorBoundary>
<Footer />
</App>
Si les recommandations échouent, tout le reste continue de fonctionner :
Header ✅
Dashboard ✅
Recommendations ❌
Footer ✅
Une frontière au niveau racine reste utile en dernier recours autour des frontières plus fines.
Le modèle mental en une image
Lazy Loading
│
▼
Code needed?
│
▼
Suspense
│
┌──────┴──────┐
│ │
Loading Ready
│ │
Fallback ▼
Render
│
┌─────┴─────┐
│ │
Success Error
│ │
▼ ▼
UI Error Boundary
│
▼
Error UI
Points clés
React.lazyavecimport()dynamique divise le code en blocs chargés lors de la première rendu ; le module doit disposer d’une export par défaut.Suspensene charge rien ; il définit l’endroit où apparaît un fallback tant que les éléments descendants sont en attente.- Les Error Boundaries captent les erreurs de rendu, de cycle de vie et de constructeur, y compris les imports différés qui ont échoué, mais pas les erreurs de gestion d’événements ou les erreurs asynchrones ultérieures.
Suspense ne gère pas les erreurs, il faut donc l’associer à une limite d’erreur.