Cours JavaScript et React · Module 16: Gestion des erreurs et Suspense
Graceful degradation et UI résiliente
Dans cette leçon6
Le serveur des statistiques de mission ne répond pas. Que doit voir l'astronaute : un écran vide avec le message "Erreur 500", ou un tableau de bord où tout fonctionne sauf une tuile qui indique que les statistiques reviendront bientôt ? La réponse est évidente, et pourtant beaucoup d'applications choisissent la première option. Sur une station spatiale, la défaillance d'un système ne peut pas signifier la fin de la mission. Chaque système critique dispose de secours, et l'équipage est entraîné à travailler en mode dégradé. Ton application React doit fonctionner de la même façon - même si quelque chose tourne mal, l'utilisateur doit pouvoir continuer à travailler.
Qu'est-ce que la graceful degradation ?
La graceful degradation (dégradation en douceur) est une philosophie de conception dans laquelle l'application continue de fonctionner même lorsque certaines de ses parties échouent - simplement avec un niveau de fonctionnalité réduit. Un vaisseau dont le télescope est endommagé continue de voler, il voit juste moins loin.
Fonctionnalité complète vs dégradation
Compare deux applications. La première affiche un écran blanc en cas de panne, la seconde confine la panne dans un seul compartiment grâce à l'Error Boundary des leçons précédentes :
1// Au lieu de : toute la page plante
2function BrokenApp() {
3 return <WhiteScreenOfDeath />; // L'utilisateur ne peut rien faire
4}
5
6// Graceful degradation : certaines parties de l'appli fonctionnent encore
7function ResilientApp() {
8 return (
9 <div>
10 <Navigation /> {/* Fonctionne toujours */}
11 <ErrorBoundary fallback={<FallbackContent />}>
12 <MainContent /> {/* Peut planter */}
13 </ErrorBoundary>
14 <Footer /> {/* Fonctionne toujours */}
15 </div>
16 );
17}Navigation et Footer se trouvent en dehors de la limite, donc une panne de MainContent ne les concerne pas. L'utilisateur peut passer à une autre page au lieu de rafraîchir toute l'application.
Le modèle Retry (nouvelle tentative)
Quand une opération échoue, donne à l'utilisateur la possibilité de réessayer. Avec react-error-boundary, il suffit de brancher resetErrorBoundary sur un bouton :
1import { ErrorBoundary } from 'react-error-boundary';
2
3function ErrorFallbackWithRetry({ error, resetErrorBoundary }) {
4 return (
5 <div className="error-panel">
6 <h3>Panne du module</h3>
7 <p>Erreur : {error.message}</p>
8 <button onClick={resetErrorBoundary}>
9 Réessayer
10 </button>
11 </div>
12 );
13}
14
15function App() {
16 return (
17 <ErrorBoundary
18 FallbackComponent={ErrorFallbackWithRetry}
19 onReset={() => {
20 // Vide le cache, réinitialise l'état, etc.
21 }}
22 >
23 <DataPanel />
24 </ErrorBoundary>
25 );
26}Le clic réinitialise la limite et React rend DataPanel de nouveau. Si la cause de la panne était passagère, le module reprend le travail, sinon le fallback réapparaît. C'est pourquoi il vaut la peine de vider, dans onReset, l'état qui a pu la provoquer.
Le modèle Fallback Content
Au lieu d'afficher une erreur, montre un contenu alternatif. Le widget météo n'est pas critique, donc en cas de panne une information honnête suffit :
1function WeatherWidget() {
2 const [data, setData] = useState(null);
3 const [error, setError] = useState(false);
4
5 useEffect(() => {
6 fetchWeather()
7 .then(setData)
8 .catch(() => setError(true));
9 }, []);
10
11 if (error) {
12 // Au lieu d'une erreur, affiche des données statiques
13 return (
14 <div className="weather-widget offline">
15 <p>Données météo indisponibles</p>
16 <p>Dernière mise à jour : il y a 2 h</p>
17 <small>Rétablissement de la connexion en cours</small>
18 </div>
19 );
20 }
21
22 if (!data) return <WeatherSkeleton />;
23 return <WeatherDisplay data={data} />;
24}Il n'y a ici aucune limite d'erreur : l'erreur asynchrone est attrapée par .catch(), et le composant décide lui-même quoi afficher. Dans une vraie application, tu calculeras la durée "il y a 2 h" à partir de la date enregistrée de la dernière récupération réussie.
Gestion des défaillances partielles
Gère les défaillances partielles - quand certaines données ne peuvent pas être récupérées. La clé, c'est Promise.allSettled, qui attend toutes les promesses et retourne pour chacune un objet dont le status vaut 'fulfilled' ou 'rejected'. Promise.all interromprait tout à la première erreur :
1function MissionDashboard() {
2 const [missions, setMissions] = useState([]);
3 const [stats, setStats] = useState(null);
4 const [statsError, setStatsError] = useState(false);
5
6 useEffect(() => {
7 // Récupère les données en parallèle
8 Promise.allSettled([
9 fetch('/api/missions').then(r => r.json()),
10 fetch('/api/stats').then(r => r.json())
11 ]).then(([missionsResult, statsResult]) => {
12 if (missionsResult.status === 'fulfilled') {
13 setMissions(missionsResult.value);
14 }
15 if (statsResult.status === 'fulfilled') {
16 setStats(statsResult.value);
17 } else {
18 setStatsError(true);
19 }
20 });
21 }, []);
22
23 return (
24 <div>
25 <h2>Panneau de mission</h2>
26
27 {/* Statistiques - peuvent être indisponibles */}
28 {statsError ? (
29 <p className="warning">Statistiques temporairement indisponibles</p>
30 ) : stats ? (
31 <StatsPanel data={stats} />
32 ) : (
33 <StatsSkeleton />
34 )}
35
36 {/* Liste des missions - on essaie toujours de l'afficher */}
37 <MissionList missions={missions} />
38 </div>
39 );
40}La panne des statistiques ne touche pas la liste des missions : chaque source a son propre chemin de succès et d'échec. Une remarque : fetch ne rejette pas la promesse avec un statut 500, donc en production vérifie aussi r.ok, comme dans la première leçon de cette localisation.
Retry avec exponential backoff
Pour les données critiques, implémente des nouvelles tentatives automatiques avec des délais croissants. L'exponential backoff signifie que chaque pause suivante est deux fois plus longue : 1 s, 2 s, 4 s, 8 s et ainsi de suite. Ainsi, tu n'inondes pas de requêtes un serveur surchargé. D'abord, la fonction utilitaire elle-même :
1async function fetchWithRetry(url, maxRetries = 3) {
2 for (let attempt = 0; attempt < maxRetries; attempt++) {
3 try {
4 const response = await fetch(url);
5 if (!response.ok) throw new Error('Request failed');
6 return await response.json();
7 } catch (error) {
8 if (attempt === maxRetries - 1) throw error;
9 // Exponential backoff : 1s, 2s (lors des tentatives suivantes 4s, 8s...)
10 const delay = Math.pow(2, attempt) * 1000;
11 await new Promise(resolve => setTimeout(resolve, delay));
12 }
13 }
14}Avec maxRetries = 3, la fonction fera trois tentatives et attendra entre elles 1 s puis 2 s. Après le dernier échec, elle n'attend plus, elle transmet simplement l'erreur plus loin. Voici maintenant le composant qui l'utilise et qui donne à l'utilisateur un bouton manuel quand l'automate abandonne :
1function CriticalData() {
2 const [data, setData] = useState(null);
3 const [error, setError] = useState(null);
4 const [retrying, setRetrying] = useState(false);
5
6 const loadData = async () => {
7 setRetrying(true);
8 setError(null);
9 try {
10 const result = await fetchWithRetry('/api/critical-data');
11 setData(result);
12 } catch (err) {
13 setError(err);
14 } finally {
15 setRetrying(false);
16 }
17 };
18
19 useEffect(() => { loadData(); }, []);
20
21 if (error) return (
22 <div>
23 <p>Échec de la récupération des données après 3 tentatives</p>
24 <button onClick={loadData}>Réessayer</button>
25 </div>
26 );
27 if (retrying) return <p>Nouvelle tentative de connexion...</p>;
28 if (!data) return <LoadingSkeleton />;
29 return <DataDisplay data={data} />;
30}L'ordre des conditions dans les return compte : d'abord l'erreur, ensuite la tentative en cours, et enfin le squelette. Mon conseil : utilise le retry automatique uniquement pour les opérations qu'on peut répéter sans risque, comme la lecture de données, jamais pour l'envoi d'un paiement ou d'un ordre de lancement.
Combiner Error Boundary et Suspense
La bonne pratique consiste à combiner les deux mécanismes. La limite d'erreur se place à l'extérieur, et Suspense à l'intérieur :
1<ErrorBoundary FallbackComponent={ErrorUI}>
2 <Suspense fallback={<LoadingSkeleton />}>
3 <LazyComponent />
4 </Suspense>
5</ErrorBoundary>L'Error Boundary attrape les erreurs, et Suspense gère le chargement. Ensemble, ils forment un système de sécurité complet. Si le téléchargement du code de LazyComponent échoue, par exemple à cause d'une connexion coupée, l'erreur arrivera à la limite extérieure au lieu de laisser un squelette éternel. Dans la prochaine leçon, tu y ajouteras le monitoring et des stratégies de récupération automatique.
Souviens-toi : une station résiliente perd des modules isolés, mais jamais toute la mission.
Code de cette leçon : App.jsx
1import React, { useState, useEffect } from 'react';
2import './styles.css';
3
4// Error Boundary avec retry
5class RetryBoundary extends React.Component {
6 constructor(props) {
7 super(props);
8 this.state = { hasError: false, error: null };
9 }
10 static getDerivedStateFromError(error) {
11 return { hasError: true, error };
12 }
13 handleRetry = () => {
14 this.setState({ hasError: false, error: null });
15 };
16 render() {
17 if (this.state.hasError) {
18 return (
19 <div className="error-box">
20 <h4>{this.props.label} - Panne</h4>
21 <p>{this.state.error.message}</p>
22 <button onClick={this.handleRetry} className="retry-btn">
23 Réessayer
24 </button>
25 </div>
26 );
27 }
28 return this.props.children;
29 }
30}
31
32// Simulation de fetchWithRetry
33async function fetchWithRetry(shouldFail, maxRetries = 3) {
34 for (let attempt = 0; attempt < maxRetries; attempt++) {
35 await new Promise(r => setTimeout(r, 500));
36 if (!shouldFail || attempt === maxRetries - 1) {
37 return { status: 'ok', data: 'Données récupérées avec succès' };
38 }
39 }
40 throw new Error('Échec après plusieurs tentatives');
41}
42
43// Widget avec contenu de repli
44function DataWidget({ title, failChance = 0.3 }) {
45 const [data, setData] = useState(null);
46 const [error, setError] = useState(false);
47 const [loading, setLoading] = useState(true);
48
49 useEffect(() => {
50 setTimeout(() => {
51 if (Math.random() < failChance) {
52 setError(true);
53 } else {
54 setData({ value: Math.floor(Math.random() * 100), unit: '%' });
55 }
56 setLoading(false);
57 }, 800 + Math.random() * 1200);
58 }, []);
59
60 if (loading) return <div className="widget loading">Chargement de {title}...</div>;
61 if (error) return (
62 <div className="widget offline">
63 <strong>{title}</strong>
64 <p>Données indisponibles</p>
65 </div>
66 );
67 return (
68 <div className="widget online">
69 <strong>{title}</strong>
70 <p className="value">{data.value}{data.unit}</p>
71 </div>
72 );
73}
74
75// Module pouvant tomber en panne
76function UnstableModule({ name }) {
77 if (Math.random() < 0.4) {
78 throw new Error(name + ' - erreur critique');
79 }
80 return (
81 <div className="module-ok">
82 <span className="dot" /> {name} : En ligne
83 </div>
84 );
85}
86
87export default function App() {
88 const [key, setKey] = useState(0);
89
90 return (
91 <div className="app">
92 <h1>Graceful Degradation</h1>
93 <p className="subtitle">Les pannes partielles ne détruisent pas toute la station</p>
94 <button onClick={() => setKey(k => k + 1)} className="reset-btn">
95 Redémarrer la station
96 </button>
97
98 <div key={key}>
99 <section>
100 <h2>Widgets avec repli (Promise.allSettled)</h2>
101 <div className="widget-grid">
102 <DataWidget title="Oxygène" failChance={0.3} />
103 <DataWidget title="Énergie" failChance={0.3} />
104 <DataWidget title="Pression" failChance={0.3} />
105 <DataWidget title="Temp." failChance={0.3} />
106 </div>
107 </section>
108
109 <section>
110 <h2>Modules avec Error Boundary + Retry</h2>
111 <div className="modules">
112 <RetryBoundary label="Moteur">
113 <UnstableModule name="Moteur principal" />
114 </RetryBoundary>
115 <RetryBoundary label="Navigation">
116 <UnstableModule name="Navigation" />
117 </RetryBoundary>
118 <RetryBoundary label="Liaison">
119 <UnstableModule name="Liaison" />
120 </RetryBoundary>
121 </div>
122 </section>
123 </div>
124 </div>
125 );
126}Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Qu'est-ce que la graceful degradation dans le contexte d'une application React ?
2. En quoi Promise.allSettled diffère-t-il de Promise.all ?
Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Éditeur de code
Implémente retry avec backoff
- Tri vertical
Ordonne les temps d'attente en exponential backoff (base 1s) :
- Éditeur de code
Implémente la gestion des défaillances partielles
- Tri vertical
Ordonne l'ordre d'enveloppement correct (du plus externe au plus interne) :
- Éditeur de code
Implémente SafeLazyLoader