Suspense affiche un contenu de repli, comme un message de chargement, tant que les composants qu'il contient ne sont pas encore prêts. React.lazy est ce qu'on attend le plus souvent : il ne charge le code d'un composant qu'au premier rendu de ce composant, ce qui garde le téléchargement initial léger.
Cliquez sur Show chart : Loading chart... apparaît pendant une seconde, puis le graphique. Masquez-le et affichez-le à nouveau : il apparaît immédiatement sans nouvelle ligne dans la console, car lazy conserve le module chargé.
Code splitting avec lazy
L'éditeur contient tout dans un seul fichier, donc l'exemple construit le module lent à la main : une promesse qui se résout au bout d'une seconde en un objet avec un export default. Dans une vraie application, le composant vit dans son propre fichier et vous passez un import dynamique :
import { lazy, Suspense } from 'react';
const Chart = lazy(() => import('./Chart.jsx'));
export default function Dashboard() {
return (
<Suspense fallback={<p>Loading chart...</p>}>
<Chart />
</Suspense>
);
}
import('./Chart.jsx') renvoie une promesse du module. Les bundlers comme Vite et webpack repèrent l'import dynamique et placent Chart.jsx et tout ce qu'il est seul à utiliser dans un fichier séparé, téléchargé au premier rendu de <Chart />. Règles à connaître :
- Le module a besoin d'un export par défaut.
lazylit la propriétédefaultde ce que la promesse renvoie. Pour un export nommé, faites la correspondance :lazy(() => import('./charts.js').then((m) => ({ default: m.LineChart }))). - Appelez
lazyau niveau supérieur d'un module. Dans un composant, il créerait un nouveau type de composant à chaque rendu : React démonterait donc l'ancien, perdrait son état et le chargerait à nouveau. - Découpez là où l'utilisateur attend de toute façon. Les routes, les modales, les panneaux rarement ouverts et les widgets lourds (éditeurs, graphiques, cartes) sont de bons candidats. Découper chaque petit composant ajoute des requêtes et des états de chargement sans rien gagner.
Précharger avant le clic
Un composant lazy commence à se charger à son premier rendu, donc l'utilisateur attend toujours au moins un téléchargement après le clic. Si vous pouvez deviner que le clic arrive, lancez le téléchargement plus tôt. Gardez la fonction d'import dans une variable et appelez-la au survol ou au focus ; le navigateur garde le module, donc quand lazy appelle plus tard le même import, il se résout sans second téléchargement.
const loadChart = () => import('./Chart.jsx');
const Chart = lazy(loadChart);
<button onMouseEnter={loadChart} onFocus={loadChart} onClick={() => setShow(true)}>
Show chart
</button>
Comment Suspense décide quoi afficher
Quand un composant à l'intérieur de <Suspense> n'est pas prêt, il se suspend : React arrête le rendu de cette partie et affiche le fallback du Suspense le plus proche au-dessus. Tout le contenu de cette frontière est remplacé par le contenu de repli, pas seulement le composant qui attend. Quand ce qu'il attendait est prêt, React refait le rendu du contenu et remplace le contenu de repli.
La position de la frontière est donc un choix de conception. Placez les parties indépendantes dans leurs propres frontières pour que chacune apparaisse quand elle est prête :
D'abord, toute la page affiche Loading page..., car Header appartient à la frontière extérieure. Une fois l'en-tête prêt, l'article apparaît avec Loading comments... en dessous, et les commentaires arrivent en dernier. Remplacez 1500 par 3000 et l'aperçu se recharge avec une attente plus longue pour les commentaires seulement. Supprimez le <Suspense> intérieur (gardez <Comments />) et la page attend les commentaires avant d'afficher quoi que ce soit.
Suspense avec use() dans React 19
Dans React 19, un composant peut lire une promesse avec use(promise). Si la promesse est encore en attente, le composant se suspend et le Suspense le plus proche affiche son contenu de repli ; quand elle se résout, use renvoie la valeur. La page sur le hook use le traite en détail. Le faux fetchUser ci-dessous remplace une vraie requête.
Parcourez les utilisateurs, puis revenez à User 1 : il apparaît instantanément et la console n'affiche aucun nouveau fetch, car la promesse est déjà dans le cache.
Pourquoi la promesse doit être mise en cache
La map cache n'est pas une optimisation ici, elle est indispensable. Un composant qui se suspend ne garde rien de cette tentative : quand la promesse se résout, React refait son rendu depuis le début. Si Profile appelait directement fetchUser(id), chaque tentative créerait une nouvelle promesse, lancerait une nouvelle requête et se suspendrait à nouveau dessus. Le profil n'apparaît jamais, et la console se remplit de lignes fetching user. La promesse doit donc venir d'un endroit qui survit au rendu :
- un cache indexé par la requête, comme la map ci-dessus (les bibliothèques de données comme TanStack Query et les loaders des frameworks le font pour vous) ;
- un parent qui crée la promesse une seule fois, dans un gestionnaire d'événement ou un Server Component, et la passe vers le bas en prop.
Notez que chaque nouvel utilisateur remplace quand même le profil par le contenu de repli. Si vous préférez garder l'ancien utilisateur à l'écran jusqu'à ce que le suivant soit prêt, enveloppez la mise à jour dans une transition : startTransition(() => setId(n)). React ne masque pas un contenu déjà visible pour une transition (voir useTransition).
Les erreurs ont besoin d'une error boundary
Suspense gère l'attente, pas l'échec. Si un import lazy échoue (l'utilisateur est passé hors ligne, un nouveau déploiement a supprimé l'ancien chunk) ou si une promesse passée à use est rejetée, React envoie l'erreur à l'error boundary la plus proche. Sans elle, tout l'arbre sous la racine est démonté. Les error boundaries sont toujours des composants classes (voir error boundaries) :
<ErrorBoundary fallback={<p>Could not load the chart.</p>}>
<Suspense fallback={<p>Loading chart...</p>}>
<Chart />
</Suspense>
</ErrorBoundary>
Ce que Suspense ne détecte pas
Suspense ne réagit qu'aux composants qui se suspendent : les composants lazy, use(promise) et les sources de données conçues pour Suspense (loaders des frameworks, bibliothèques compatibles avec Suspense). Un fetch dans useEffect qui modifie l'état à la fin ne se suspend pas, donc une frontière Suspense autour de lui n'affiche jamais son contenu de repli. Pour ce pattern, vous gardez votre propre état loading, comme le montre la page sur la récupération de données.
Questions fréquentes
Qu'est-ce que React Suspense ?
<Suspense fallback={...}> est un composant qui affiche son fallback tant qu'un composant à l'intérieur attend quelque chose, comme du code chargé en lazy ou des données lues avec use. Quand tout ce qu'il contient est prêt, React remplace le contenu de repli par le contenu.
Que fait React.lazy ?
lazy(() => import('./Chart.jsx')) crée un composant dont le code est téléchargé à son premier rendu. Les bundlers placent ce fichier dans un chunk séparé, donc la page initiale charge moins de JavaScript.
Suspense fonctionne-t-il pour la récupération de données ?
Oui, quand la source de données le prend en charge. Dans React 19, un composant peut lire une promesse avec use(promise) et se suspendre jusqu'à sa résolution. Des frameworks comme Next.js intègrent aussi Suspense à leur chargement de données. Un fetch dans useEffect ne déclenche pas Suspense.
Comment gérer les erreurs avec Suspense ?
Suspense ne gère que l'attente. Si un import lazy ou une promesse échoue, l'erreur va à l'error boundary la plus proche, enveloppez donc la frontière Suspense (ou son parent) dans une error boundary.
Où appeler lazy ?
Au niveau supérieur d'un module, hors de tout composant. Appeler lazy dans un composant crée un nouveau type de composant à chaque rendu, ce qui réinitialise son état et le recharge.