Cours JavaScript et React · Module 16: Gestion des erreurs et Suspense

Projet principal : Resilient Space Dashboard

7 min de lecture
Dans cette leçon5

Le centre de contrôle de mission a une dernière tâche pour toi dans cette localisation. Le tableau de bord que regarde tout l'équipage ne peut pas s'éteindre simplement parce qu'un capteur a envoyé des données corrompues ou que le serveur des statistiques a pris un jour de congé. Il est temps de combiner toutes les techniques apprises en un seul projet ! Tu vas créer un tableau de bord spatial résistant aux erreurs qui met en œuvre les Error Boundaries, Suspense, les skeleton loaders, la graceful degradation et les modèles de retry.

Exigences du projet

Ton tableau de bord doit inclure :

  1. Error Boundary au niveau application - attrape les erreurs critiques
  2. Error Boundaries au niveau section - isolent les pannes de modules
  3. Suspense avec skeleton loaders - chargement élégant des composants
  4. Mécanisme de retry - possibilité de réessayer après une erreur
  5. Graceful degradation - les défaillances partielles ne détruisent pas toute l'application

Chaque point correspond à l'une des leçons précédentes, donc tu n'as rien à inventer à partir de zéro. Ta tâche est d'assembler ces éléments pour qu'ils fonctionnent ensemble.

Architecture du projet

Avant d'écrire le premier composant, dessine l'arbre. La limite extérieure AppErrorBoundary est la dernière ligne de défense, et chaque section reçoit sa propre paire : la limite d'erreur à l'extérieur et Suspense à l'intérieur, exactement dans l'ordre que tu as vu en combinant les deux mécanismes :

1// Structure du Resilient Space Dashboard
2<AppErrorBoundary>
3  <SpaceDashboard>
4    <Header />
5
6    <ErrorBoundary fallback={<MissionFallback />}>
7      <Suspense fallback={<MissionSkeleton />}>
8        <MissionControl />
9      </Suspense>
10    </ErrorBoundary>
11
12    <ErrorBoundary fallback={<SystemFallback />}>
13      <Suspense fallback={<SystemSkeleton />}>
14        <SystemStatus />
15      </Suspense>
16    </ErrorBoundary>
17
18    <ErrorBoundary fallback={<CrewFallback />}>
19      <CrewPanel />
20    </ErrorBoundary>
21  </SpaceDashboard>
22</AppErrorBoundary>

Header se trouve en dehors des limites des sections, donc il restera à l'écran même si les trois modules s'éteignent. CrewPanel n'a pas de Suspense, car dans cette ébauche il est importé normalement, et non via React.lazy().

Éléments clés à implémenter

1. Error Boundary réutilisable avec Retry

Commence par une classe qui, en plus du fallback, possède une méthode handleRetry. Un champ de classe écrit comme fonction fléchée a automatiquement le bon this, donc tu peux le brancher directement sur onClick :

1class RetryErrorBoundary extends React.Component {
2  constructor(props) {
3    super(props);
4    this.state = { hasError: false, error: null };
5  }
6
7  static getDerivedStateFromError(error) {
8    return { hasError: true, error };
9  }
10
11  handleRetry = () => {
12    this.setState({ hasError: false, error: null });
13  };
14
15  render() {
16    if (this.state.hasError) {
17      return (
18        <div className="error-panel">
19          <h3>{this.props.title || 'Panne du module'}</h3>
20          <p>{this.state.error.message}</p>
21          <button onClick={this.handleRetry}>Restart</button>
22        </div>
23      );
24    }
25    return this.props.children;
26  }
27}

Remettre hasError à zéro fait que render() retourne de nouveau les enfants. Comme ils ont été démontés pendant la panne, ils repartent d'un état vierge, et c'est exactement cela, le redémarrage du module. La prop title permet de nommer chaque section dans le fallback.

2. Skeleton Loader pour les sections

Le squelette universel accepte un nombre de lignes. Nous calculons la largeur de chaque ligne à partir de son index, ce qui donne des lignes naturellement inégales :

1function SectionSkeleton({ lines = 3 }) {
2  return (
3    <div className="section-skeleton">
4      <div className="skeleton-header skeleton-pulse" />
5      {Array.from({ length: lines }).map((_, i) => (
6        <div
7          key={i}
8          className="skeleton-line skeleton-pulse"
9          style={{ width: `${60 + ((i * 17) % 40)}%` }}
10        />
11      ))}
12    </div>
13  );
14}

Nous n'utilisons volontairement pas Math.random() ici : un composant React doit être pur, et une largeur aléatoire changerait à chaque nouveau rendu, si bien que le squelette "tremblerait". La formule basée sur l'index donne toujours les mêmes valeurs, de 60% à 99%.

3. Composant avec simulation d'erreur

Pour tester les limites, tu as besoin d'un module qui tombe parfois en panne. Attention au piège : une erreur levée à l'intérieur de setTimeout contournerait l'Error Boundary, car elle survient en dehors du rendu. C'est pourquoi nous enregistrons la panne dans l'état et ne levons l'erreur que pendant le rendu :

1function UnstableModule({ failRate = 0.3 }) {
2  const [data, setData] = useState(null);
3  const [failed, setFailed] = useState(false);
4
5  useEffect(() => {
6    // Simule une API instable
7    const timer = setTimeout(() => {
8      if (Math.random() < failRate) {
9        // Une erreur levée ici contournerait l'Error Boundary,
10        // donc on enregistre seulement la panne dans l'état
11        setFailed(true);
12        return;
13      }
14      setData({ status: 'operational', power: 98 });
15    }, 1000);
16    return () => clearTimeout(timer);
17  }, [failRate]);
18
19  // On lève l'erreur pendant le rendu - l'Error Boundary l'attrapera
20  if (failed) throw new Error('Module malfunction detected!');
21
22  if (!data) return <p>Initialisation...</p>;
23  return (
24    <div className="module-status">
25      <span>Statut : {data.status}</span>
26      <span>Puissance : {data.power}%</span>
27    </div>
28  );
29}

Math.random() ne pose pas de problème ici, car il s'exécute dans un effet, et non pendant le rendu. Mets failRate={1} pour forcer une panne et vérifier que le fallback n'apparaît que dans une seule section.

4. Graceful degradation - fonctionnalité partielle

Quand un module tombe en panne, le reste du tableau de bord doit fonctionner normalement. Le fallback de la section dit clairement ce qui est hors ligne et rassure sur le fait que le reste fonctionne :

1function FallbackPanel({ title, onRetry }) {
2  return (
3    <div className="fallback-panel">
4      <h3>{title} - Hors ligne</h3>
5      <p>Ce module est temporairement indisponible.</p>
6      <p>Les autres systèmes fonctionnent normalement.</p>
7      <button onClick={onRetry}>Réessayer</button>
8    </div>
9  );
10}

Tu peux relier la prop onRetry à resetErrorBoundary de la bibliothèque ou à handleRetry de ta propre classe.

5. Retry avec limite de tentatives

Le dernier élément est un hook qui veille à ce que l'équipage ne clique pas sur "Réessayer" à l'infini :

1function useRetry(asyncFn, maxRetries = 3) {
2  const [retryCount, setRetryCount] = useState(0);
3  const [error, setError] = useState(null);
4
5  const retry = async () => {
6    if (retryCount >= maxRetries) {
7      setError(new Error('Limite de tentatives dépassée'));
8      return;
9    }
10    try {
11      setRetryCount(prev => prev + 1);
12      await asyncFn();
13      setError(null);
14    } catch (err) {
15      setError(err);
16    }
17  };
18
19  return { retry, error, retryCount, canRetry: retryCount < maxRetries };
20}

Le hook retourne canRetry, donc tu peux simplement désactiver le bouton quand la limite est épuisée, au lieu d'afficher l'erreur seulement après le clic.

Conseils d'implémentation

  • Utilise class ErrorBoundary extends React.Component pour des Error Boundaries personnalisées
  • Donne à chaque module sa propre Error Boundary - une panne dans l'un ne doit pas détruire tout le tableau de bord
  • Ajoute un bouton "Redémarrer le module" dans l'UI de repli de chaque Error Boundary
  • Crée des skeleton loaders réutilisables correspondant à chaque section
  • Combine Error Boundary + Suspense pour les composants chargés dynamiquement
  • Ajoute un style cosmique - fond sombre, accents néon, thème de station spatiale

Mon conseil : au lieu d'écrire trois classes, utilise une seule RetryErrorBoundary ou l'ErrorBoundary de react-error-boundary, et change seulement le title et le fallback. Moins de code, c'est moins d'endroits où une panne peut survenir.

Critères d'évaluation

  1. Error Boundary au niveau application - attrape les erreurs critiques
  2. Au moins 3 sections avec des Error Boundaries distinctes - chacune peut "planter" indépendamment
  3. Suspense utilisé avec au moins un composant chargé en lazy
  4. Skeleton loader - visible pendant le chargement
  5. Bouton Retry - possibilité de réessayer après une erreur
  6. Graceful degradation - le reste du tableau de bord fonctionne malgré la panne d'une section

Examine l'éditeur ci-dessous contenant l'exemple d'implémentation et modifie-le selon tes besoins ! Tu enverras le projet terminé à ton mentor dans la tâche suivante, et savoir construire des interfaces résilientes te servira dans chaque application à venir, des formulaires aux tableaux de bord avec des données en direct.

Souviens-toi : un tableau de bord de mission vraiment résilient éteint des voyants isolés, mais ne laisse jamais l'équipage dans le noir.

Code de cette leçon : App.jsx
1import React, { useState, useEffect, Suspense } from 'react';
2import './styles.css';
3
4// === Error Boundary avec retry ===
5class ErrorBoundary 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  componentDidCatch(error, info) {
14    console.error('[' + (this.props.section || '?') + ']', error.message);
15  }
16  handleRetry = () => {
17    this.setState({ hasError: false, error: null });
18  };
19  render() {
20    if (this.state.hasError) {
21      return (
22        <div className="err-box">
23          <h4>{this.props.section || 'Module'} - Panne</h4>
24          <p>{this.state.error.message}</p>
25          <button onClick={this.handleRetry} className="retry-btn">Redémarrer</button>
26        </div>
27      );
28    }
29    return this.props.children;
30  }
31}
32
33// === Skeleton Loaders ===
34function CardSkeleton() {
35  return (
36    <div className="skel-card">
37      <div className="skel-h skeleton-pulse" />
38      <div className="skel-p skeleton-pulse" />
39      <div className="skel-p skeleton-pulse short" />
40    </div>
41  );
42}
43
44function ListSkeleton({ count = 3 }) {
45  return (
46    <div className="skel-list">
47      {Array.from({ length: count }).map((_, i) => (
48        <div key={i} className="skel-row">
49          <div className="skel-dot skeleton-pulse" />
50          <div className="skel-line skeleton-pulse" />
51        </div>
52      ))}
53    </div>
54  );
55}
56
57// === Lazy Components ===
58const MissionControl = React.lazy(() =>
59  new Promise(resolve =>
60    setTimeout(() => resolve({
61      default: function MissionCtrl() {
62        const missions = [
63          { id: 1, name: 'Exploration de Mars', status: 'active' },
64          { id: 2, name: 'Sonde jovienne', status: 'pending' },
65          { id: 3, name: 'Base lunaire', status: 'completed' },
66        ];
67        return (
68          <div className="panel">
69            <h3>Centre de missions</h3>
70            {missions.map(m => (
71              <div key={m.id} className={`mission-item ${m.status}`}>
72                <span>{m.name}</span>
73                <span className="badge">{m.status}</span>
74              </div>
75            ))}
76          </div>
77        );
78      }
79    }), 1500)
80  )
81);
82
83// Unstable component for testing Error Boundary
84function SystemStatus() {
85  const [systems] = useState(() => {
86    return [
87      { id: 1, name: 'Moteur', level: Math.floor(Math.random() * 30) + 70 },
88      { id: 2, name: 'Oxygène', level: Math.floor(Math.random() * 20) + 80 },
89      { id: 3, name: 'Énergie', level: Math.floor(Math.random() * 40) + 60 },
90    ];
91  });
92
93  // 30% de chances d'échec
94  if (Math.random() < 0.3) {
95    throw new Error('Panne du système de diagnostic !');
96  }
97
98  return (
99    <div className="panel">
100      <h3>Statut des systèmes</h3>
101      {systems.map(s => (
102        <div key={s.id} className="sys-row">
103          <span>{s.name}</span>
104          <div className="bar-bg">
105            <div
106              className="bar-fill"
107              style={{
108                width: s.level + '%',
109                background: s.level > 70 ? '#4caf50' : '#ff9800',
110              }}
111            />
112          </div>
113          <span className="pct">{s.level}%</span>
114        </div>
115      ))}
116    </div>
117  );
118}
119
120// Panneau d'équipage avec données async
121function CrewPanel() {
122  const [crew, setCrew] = useState(null);
123  const [error, setError] = useState(false);
124
125  useEffect(() => {
126    setTimeout(() => {
127      if (Math.random() < 0.2) {
128        setError(true);
129      } else {
130        setCrew([
131          { id: 1, name: 'Cpt. Nova', role: 'Commandant', status: 'online' },
132          { id: 2, name: 'Dr. Stellar', role: 'Science', status: 'online' },
133          { id: 3, name: 'Ing. Orion', role: 'Technique', status: 'busy' },
134        ]);
135      }
136    }, 1000);
137  }, []);
138
139  if (error) {
140    return (
141      <div className="panel offline-panel">
142        <h3>Équipage</h3>
143        <p className="offline-msg">Données de l'équipage temporairement indisponibles</p>
144      </div>
145    );
146  }
147
148  if (!crew) {
149    return (
150      <div className="panel">
151        <h3>Équipage</h3>
152        <ListSkeleton count={3} />
153      </div>
154    );
155  }
156
157  return (
158    <div className="panel">
159      <h3>Équipage</h3>
160      {crew.map(c => (
161        <div key={c.id} className="crew-row">
162          <span className={`crew-dot ${c.status}`} />
163          <strong>{c.name}</strong>
164          <span className="crew-role">{c.role}</span>
165        </div>
166      ))}
167    </div>
168  );
169}
170
171export default function App() {
172  const [key, setKey] = useState(0);
173
174  return (
175    <div className="app">
176      <header className="header">
177        <h1>Resilient Space Dashboard</h1>
178        <button onClick={() => setKey(k => k + 1)} className="reset-btn">
179          Restart
180        </button>
181      </header>
182
183      <div className="dashboard" key={key}>
184        {/* Centre de missions : Suspense + Error Boundary */}
185        <ErrorBoundary section="Missions">
186          <Suspense fallback={<CardSkeleton />}>
187            <MissionControl />
188          </Suspense>
189        </ErrorBoundary>
190
191        {/* Statut des systèmes : Error Boundary avec retry */}
192        <ErrorBoundary section="Systèmes">
193          <SystemStatus />
194        </ErrorBoundary>
195
196        {/* Panneau d'équipage : Graceful degradation */}
197        <ErrorBoundary section="Équipage">
198          <CrewPanel />
199        </ErrorBoundary>
200      </div>
201    </div>
202  );
203}

Tu as repéré une erreur dans cette leçon ?

Articles utiles