Kurs JavaScript i React · Moduł 16: Obsługa błędów i Suspense

Projekt główny: Resilient Space Dashboard

6 min czytania
W tej lekcji5

Centrum kontroli misji ma dla Ciebie ostatnie zadanie w tej lokacji. Pulpit, na który patrzy cała załoga, nie może zgasnąć tylko dlatego, że jeden czujnik przysłał śmieciowe dane albo serwer statystyk wziął wolne. Czas połączyć wszystkie poznane techniki w jednym projekcie! Stworzysz odporny na błędy kosmiczny dashboard, który demonstruje Error Boundaries, Suspense, skeleton loadery, graceful degradation i wzorce retry.

Wymagania projektu

Twój dashboard powinien zawierać:

  1. Error Boundary na poziomie aplikacji - przechwytuje krytyczne błędy
  2. Error Boundaries na poziomie sekcji - izolują awarie modułów
  3. Suspense z skeleton loaderami - ładne ładowanie komponentów
  4. Mechanizm retry - możliwość ponownej próby po błędzie
  5. Graceful degradation - częściowe awarie nie niszczą całej aplikacji

Każdy punkt to jedna z poprzednich lekcji, więc niczego nie musisz wymyślać od zera. Twoim zadaniem jest złożyć te elementy tak, żeby działały razem.

Architektura projektu

Zanim napiszesz pierwszy komponent, narysuj drzewo. Zewnętrzna granica AppErrorBoundary to ostatnia linia obrony, a każda sekcja dostaje własną parę: granicę błędów na zewnątrz i Suspense w środku, dokładnie w tej kolejności, którą poznałeś przy łączeniu obu mechanizmów:

1// Struktura 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 stoi poza granicami sekcji, więc zostanie na ekranie nawet wtedy, gdy wszystkie trzy moduły zgasną. CrewPanel nie ma Suspense, bo w tym szkicu jest importowany normalnie, a nie przez React.lazy().

Kluczowe elementy do zaimplementowania

1. Reużywalny Error Boundary z retry

Zacznij od klasy, która oprócz fallbacku ma metodę handleRetry. Pole klasy zapisane jako funkcja strzałkowa ma automatycznie poprawne this, więc możesz je podpiąć bezpośrednio pod 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 || 'Awaria modułu'}</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}

Wyczyszczenie hasError sprawia, że render() znowu zwraca dzieci. Ponieważ podczas awarii były one odmontowane, zaczynają od czystego stanu, a to właśnie jest restart modułu. Prop title pozwala nazwać każdą sekcję we fallbacku.

2. Skeleton Loader dla sekcji

Uniwersalny szkielet przyjmuje liczbę linii. Szerokość każdej linii wyliczamy z jej indeksu, dzięki czemu linie wyglądają naturalnie nierówno:

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}

Celowo nie używamy tu Math.random(): komponent React powinien być czysty, a losowa szerokość zmieniałaby się przy każdym ponownym renderze i szkielet by "drgał". Wzór z indeksem daje zawsze te same wartości, od 60% do 99%.

3. Komponent z symulacją błędów

Żeby przetestować granice, potrzebujesz modułu, który czasem się psuje. Uwaga na pułapkę: błąd rzucony wewnątrz setTimeout ominąłby Error Boundary, bo powstaje poza renderowaniem. Dlatego zapisujemy awarię w stanie i rzucamy błąd dopiero podczas renderu:

1function UnstableModule({ failRate = 0.3 }) {
2  const [data, setData] = useState(null);
3  const [failed, setFailed] = useState(false);
4
5  useEffect(() => {
6    // Symulacja niestabilnego API
7    const timer = setTimeout(() => {
8      if (Math.random() < failRate) {
9        // Błąd rzucony tutaj ominąłby Error Boundary,
10        // więc tylko zapisujemy awarię w stanie
11        setFailed(true);
12        return;
13      }
14      setData({ status: 'operational', power: 98 });
15    }, 1000);
16    return () => clearTimeout(timer);
17  }, [failRate]);
18
19  // Rzucamy podczas renderowania - to Error Boundary już przechwyci
20  if (failed) throw new Error('Module malfunction detected!');
21
22  if (!data) return <p>Inicjalizacja...</p>;
23  return (
24    <div className="module-status">
25      <span>Status: {data.status}</span>
26      <span>Moc: {data.power}%</span>
27    </div>
28  );
29}

Math.random() jest tu w porządku, bo działa w efekcie, a nie podczas renderowania. Ustaw failRate={1}, żeby wymusić awarię i sprawdzić, czy fallback pojawia się tylko w jednej sekcji.

4. Graceful degradation - częściowa funkcjonalność

Gdy jeden moduł zawiedzie, reszta dashboardu powinna działać normalnie. Fallback sekcji mówi wprost, co jest offline, i uspokaja, że reszta pracuje:

1function FallbackPanel({ title, onRetry }) {
2  return (
3    <div className="fallback-panel">
4      <h3>{title} - Offline</h3>
5      <p>Ten moduł jest tymczasowo niedostępny.</p>
6      <p>Pozostałe systemy działają normalnie.</p>
7      <button onClick={onRetry}>Ponów próbę</button>
8    </div>
9  );
10}

Prop onRetry możesz połączyć z resetErrorBoundary z biblioteki albo z handleRetry z własnej klasy.

5. Retry z limitem prób

Ostatni element to hook, który pilnuje, żeby załoga nie klikała "Ponów" w nieskończoność:

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('Przekroczono limit prób'));
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}

Hook zwraca canRetry, więc przycisk możesz po prostu wyłączyć, gdy limit się wyczerpie, zamiast pokazywać błąd dopiero po kliknięciu.

Podpowiedzi implementacyjne

  • Używaj class ErrorBoundary extends React.Component dla własnych Error Boundaries
  • Każ każdemu modułowi mieć własny Error Boundary - awaria jednego nie powinna niszczyć całego dashboardu
  • Dodaj przycisk "Restart modułu" w fallback UI każdego Error Boundary
  • Stwórz reużywalne skeleton loadery pasujące do każdej sekcji
  • Połącz Error Boundary + Suspense dla komponentów ładowanych dynamicznie
  • Dodaj kosmiczną stylistykę - ciemne tło, neonowe akcenty, motyw stacji kosmicznej

Moja rada: zamiast pisać trzy klasy, użyj jednej RetryErrorBoundary albo ErrorBoundary z react-error-boundary i zmieniaj tylko title oraz fallback. Mniej kodu to mniej miejsc na awarię.

Kryteria oceny

  1. Error Boundary na poziomie aplikacji - przechwytuje krytyczne błędy
  2. Min. 3 sekcje z osobnymi Error Boundaries - każda może się "zepsuć" niezależnie
  3. Suspense użyty z co najmniej jednym lazy-loaded komponentem
  4. Skeleton loader - widoczny podczas ładowania
  5. Przycisk retry - możliwość ponownej próby po błędzie
  6. Graceful degradation - reszta dashboardu działa mimo awarii jednej sekcji

Przejrzyj poniższy edytor z przykładową implementacją i zmodyfikuj ją według swoich potrzeb! Gotowy projekt wyślesz mentorowi w kolejnym zadaniu, a umiejętność budowania odpornych interfejsów przyda Ci się w każdej kolejnej aplikacji, od formularzy po pulpity z danymi na żywo.

Pamiętaj: prawdziwie odporny pulpit misji gasi pojedyncze kontrolki, ale nigdy nie zostawia załogi w ciemności.

Kod do tej lekcji: App.jsx
1import React, { useState, useEffect, Suspense } from 'react';
2import './styles.css';
3
4// === Error Boundary z 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 || 'Modul'} - Awaria</h4>
24          <p>{this.state.error.message}</p>
25          <button onClick={this.handleRetry} className="retry-btn">Restart</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: 'Eksploracja Marsa', status: 'active' },
64          { id: 2, name: 'Sonda Jowiszowa', status: 'pending' },
65          { id: 3, name: 'Baza Ksiezycowa', status: 'completed' },
66        ];
67        return (
68          <div className="panel">
69            <h3>Centrum Misji</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: 'Silnik', level: Math.floor(Math.random() * 30) + 70 },
88      { id: 2, name: 'Tlen', level: Math.floor(Math.random() * 20) + 80 },
89      { id: 3, name: 'Energia', level: Math.floor(Math.random() * 40) + 60 },
90    ];
91  });
92
93  // 30% chance of failure
94  if (Math.random() < 0.3) {
95    throw new Error('Awaria systemu diagnostycznego!');
96  }
97
98  return (
99    <div className="panel">
100      <h3>Status Systemow</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// Crew panel with async data
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: 'Kpt. Nova', role: 'Dowodca', status: 'online' },
132          { id: 2, name: 'Dr. Stellar', role: 'Nauka', status: 'online' },
133          { id: 3, name: 'Ing. Orion', role: 'Technika', status: 'busy' },
134        ]);
135      }
136    }, 1000);
137  }, []);
138
139  if (error) {
140    return (
141      <div className="panel offline-panel">
142        <h3>Zaloga</h3>
143        <p className="offline-msg">Dane zalogi chwilowo niedostepne</p>
144      </div>
145    );
146  }
147
148  if (!crew) {
149    return (
150      <div className="panel">
151        <h3>Zaloga</h3>
152        <ListSkeleton count={3} />
153      </div>
154    );
155  }
156
157  return (
158    <div className="panel">
159      <h3>Zaloga</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        {/* Mission Control: Suspense + Error Boundary */}
185        <ErrorBoundary section="Misje">
186          <Suspense fallback={<CardSkeleton />}>
187            <MissionControl />
188          </Suspense>
189        </ErrorBoundary>
190
191        {/* System Status: Error Boundary with retry */}
192        <ErrorBoundary section="Systemy">
193          <SystemStatus />
194        </ErrorBoundary>
195
196        {/* Crew Panel: Graceful degradation */}
197        <ErrorBoundary section="Zaloga">
198          <CrewPanel />
199        </ErrorBoundary>
200      </div>
201    </div>
202  );
203}

Widzisz błąd w tej lekcji?

Przydatne artykuły