Kurs JavaScript i React · Moduł 16: Obsługa błędów i Suspense
Projekt główny: Resilient Space Dashboard
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ć:
- Error Boundary na poziomie aplikacji - przechwytuje krytyczne błędy
- Error Boundaries na poziomie sekcji - izolują awarie modułów
- Suspense z skeleton loaderami - ładne ładowanie komponentów
- Mechanizm retry - możliwość ponownej próby po błędzie
- 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.Componentdla 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
- Error Boundary na poziomie aplikacji - przechwytuje krytyczne błędy
- Min. 3 sekcje z osobnymi Error Boundaries - każda może się "zepsuć" niezależnie
- Suspense użyty z co najmniej jednym lazy-loaded komponentem
- Skeleton loader - widoczny podczas ładowania
- Przycisk retry - możliwość ponownej próby po błędzie
- 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?