Kurs JavaScript i React · Moduł 16: Obsługa błędów i Suspense
Graceful Degradation i Resilient UI
W tej lekcji6
Serwer ze statystykami misji nie odpowiada. Co powinien zobaczyć astronauta: pusty ekran z komunikatem "Błąd 500" czy pulpit, na którym działa wszystko poza jednym kafelkiem z informacją, że statystyki wrócą za chwilę? Odpowiedź jest oczywista, a mimo to wiele aplikacji wybiera pierwszą opcję. Na stacji kosmicznej awaria jednego systemu nie może oznaczać końca misji. Każdy krytyczny system ma kopie zapasowe, a załoga jest przeszkolona do pracy w trybie awaryjnym. Twoja aplikacja React powinna działać tak samo - nawet jeśli coś pójdzie nie tak, użytkownik powinien móc kontynuować pracę.
Czym jest Graceful Degradation?
Graceful degradation (łagodna degradacja) to filozofia projektowania, w której aplikacja kontynuuje działanie nawet, gdy niektóre jej części zawiodą - po prostu z obniżonym poziomem funkcjonalności. Statek z uszkodzonym teleskopem nadal leci, tylko widzi mniej.
Pełna funkcjonalność vs. Degradacja
Porównaj dwie aplikacje. Pierwsza przy awarii pokazuje biały ekran, druga zamyka awarię w jednym przedziale dzięki Error Boundary z poprzednich lekcji:
1// Zamiast: awaria całej strony
2function BrokenApp() {
3 return <WhiteScreenOfDeath />; // Użytkownik nie może nic zrobić
4}
5
6// Graceful degradation: części aplikacji nadal działają
7function ResilientApp() {
8 return (
9 <div>
10 <Navigation /> {/* Zawsze działa */}
11 <ErrorBoundary fallback={<FallbackContent />}>
12 <MainContent /> {/* Może się zepsuć */}
13 </ErrorBoundary>
14 <Footer /> {/* Zawsze działa */}
15 </div>
16 );
17}Navigation i Footer stoją poza granicą, więc awaria MainContent ich nie dotyczy. Użytkownik może przejść na inną stronę, zamiast odświeżać całą aplikację.
Wzorzec Retry (ponowna próba)
Gdy operacja się nie powiedzie, daj użytkownikowi możliwość ponownej próby. Z react-error-boundary wystarczy podpiąć resetErrorBoundary pod przycisk:
1import { ErrorBoundary } from 'react-error-boundary';
2
3function ErrorFallbackWithRetry({ error, resetErrorBoundary }) {
4 return (
5 <div className="error-panel">
6 <h3>Awaria modułu</h3>
7 <p>Błąd: {error.message}</p>
8 <button onClick={resetErrorBoundary}>
9 Ponowna próba
10 </button>
11 </div>
12 );
13}
14
15function App() {
16 return (
17 <ErrorBoundary
18 FallbackComponent={ErrorFallbackWithRetry}
19 onReset={() => {
20 // Wyczyść cache, zresetuj stan itp.
21 }}
22 >
23 <DataPanel />
24 </ErrorBoundary>
25 );
26}Kliknięcie czyści granicę i React renderuje DataPanel od nowa. Jeśli przyczyna awarii była chwilowa, moduł wraca do pracy, a jeśli nie, fallback pojawi się ponownie. Dlatego w onReset warto wyczyścić stan, który mógł ją spowodować.
Wzorzec Fallback Content
Zamiast pokazywać błąd, pokaż alternatywną treść. Widżet pogody nie jest krytyczny, więc przy awarii wystarczy uczciwa informacja:
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 // Zamiast błędu, pokaż statyczne dane
13 return (
14 <div className="weather-widget offline">
15 <p>Dane pogodowe niedostępne</p>
16 <p>Ostatnia aktualizacja: 2h temu</p>
17 <small>Pracujemy nad przywróceniem połączenia</small>
18 </div>
19 );
20 }
21
22 if (!data) return <WeatherSkeleton />;
23 return <WeatherDisplay data={data} />;
24}Tu nie ma żadnej granicy błędów: błąd asynchroniczny łapie .catch(), a komponent sam decyduje, co pokazać. W prawdziwej aplikacji czas "2h temu" wyliczysz z zapisanej daty ostatniego udanego pobrania.
Partial Failure Handling
Obsługuj częściowe awarie - gdy niektórych danych nie można pobrać. Kluczem jest Promise.allSettled, które czeka na wszystkie obietnice i zwraca dla każdej obiekt ze status równym 'fulfilled' albo 'rejected'. Promise.all przerwałoby wszystko przy pierwszym błędzie:
1function MissionDashboard() {
2 const [missions, setMissions] = useState([]);
3 const [stats, setStats] = useState(null);
4 const [statsError, setStatsError] = useState(false);
5
6 useEffect(() => {
7 // Pobierz dane równolegle
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>Panel misji</h2>
26
27 {/* Statystyki - mogą być niedostępne */}
28 {statsError ? (
29 <p className="warning">Statystyki chwilowo niedostępne</p>
30 ) : stats ? (
31 <StatsPanel data={stats} />
32 ) : (
33 <StatsSkeleton />
34 )}
35
36 {/* Lista misji - zawsze próbujemy wyświetlić */}
37 <MissionList missions={missions} />
38 </div>
39 );
40}Awaria statystyk nie rusza listy misji: każde źródło ma własną ścieżkę sukcesu i porażki. Jedna uwaga: fetch nie odrzuca obietnicy przy statusie 500, więc w produkcji sprawdź też r.ok, tak jak w pierwszej lekcji tej lokacji.
Retry z exponential backoff
Dla krytycznych danych implementuj automatyczne ponowne próby z rosnącymi opóźnieniami. Exponential backoff oznacza, że każda kolejna przerwa jest dwa razy dłuższa: 1 s, 2 s, 4 s, 8 s i tak dalej. Dzięki temu nie zasypujesz przeciążonego serwera żądaniami. Najpierw sama funkcja pomocnicza:
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 (przy kolejnych próbach 4s, 8s...)
10 const delay = Math.pow(2, attempt) * 1000;
11 await new Promise(resolve => setTimeout(resolve, delay));
12 }
13 }
14}Przy maxRetries = 3 funkcja wykona trzy próby, a między nimi odczeka 1 s i 2 s. Po ostatniej porażce nie czeka już, tylko rzuca błąd dalej. Teraz komponent, który z niej korzysta i daje użytkownikowi ręczny przycisk, gdy automat się podda:
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>Nie udało się pobrać danych po 3 próbach</p>
24 <button onClick={loadData}>Spróbuj ponownie</button>
25 </div>
26 );
27 if (retrying) return <p>Ponawiam próbę połączenia...</p>;
28 if (!data) return <LoadingSkeleton />;
29 return <DataDisplay data={data} />;
30}Kolejność warunków w return ma znaczenie: najpierw błąd, potem trwająca próba, na końcu szkielet. Moja rada: automatyczny retry stosuj tylko dla operacji bezpiecznych do powtórzenia, takich jak odczyt danych, nigdy dla wysłania płatności czy rozkazu startu.
Łączenie Error Boundary z Suspense
Najlepsza praktyka to łączenie obu mechanizmów. Granica błędów stoi na zewnątrz, Suspense w środku:
1<ErrorBoundary FallbackComponent={ErrorUI}>
2 <Suspense fallback={<LoadingSkeleton />}>
3 <LazyComponent />
4 </Suspense>
5</ErrorBoundary>Error Boundary przechwytuje błędy, a Suspense obsługuje ładowanie. Razem tworzą kompletny system bezpieczeństwa. Jeśli pobranie kodu LazyComponent się nie uda, na przykład przez zerwane połączenie, błąd trafi do zewnętrznej granicy zamiast zostawić wieczny szkielet. W następnej lekcji dołożysz do tego monitoring i strategie automatycznego odzyskiwania.
Pamiętaj: odporna stacja traci pojedyncze moduły, ale nigdy całej misji.
Kod do tej lekcji: App.jsx
1import React, { useState, useEffect } from 'react';
2import './styles.css';
3
4// Error Boundary z 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} - Awaria</h4>
21 <p>{this.state.error.message}</p>
22 <button onClick={this.handleRetry} className="retry-btn">
23 Ponow probe
24 </button>
25 </div>
26 );
27 }
28 return this.props.children;
29 }
30}
31
32// Symulacja 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: 'Dane pobrane pomyslnie' };
38 }
39 }
40 throw new Error('Nie udalo sie po wielu probach');
41}
42
43// Widget z fallback content
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">Ladowanie {title}...</div>;
61 if (error) return (
62 <div className="widget offline">
63 <strong>{title}</strong>
64 <p>Dane niedostepne</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// Modul z mozliwoscia awarii
76function UnstableModule({ name }) {
77 if (Math.random() < 0.4) {
78 throw new Error(name + ' - blad krytyczny');
79 }
80 return (
81 <div className="module-ok">
82 <span className="dot" /> {name}: Online
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">Czesciowe awarie nie niszcza calej stacji</p>
94 <button onClick={() => setKey(k => k + 1)} className="reset-btn">
95 Restart stacji
96 </button>
97
98 <div key={key}>
99 <section>
100 <h2>Widgety z fallback (Promise.allSettled)</h2>
101 <div className="widget-grid">
102 <DataWidget title="Tlen" failChance={0.3} />
103 <DataWidget title="Energia" failChance={0.3} />
104 <DataWidget title="Cisnienie" failChance={0.3} />
105 <DataWidget title="Temp." failChance={0.3} />
106 </div>
107 </section>
108
109 <section>
110 <h2>Moduly z Error Boundary + Retry</h2>
111 <div className="modules">
112 <RetryBoundary label="Silnik">
113 <UnstableModule name="Silnik glowny" />
114 </RetryBoundary>
115 <RetryBoundary label="Nawigacja">
116 <UnstableModule name="Nawigacja" />
117 </RetryBoundary>
118 <RetryBoundary label="Lacznosc">
119 <UnstableModule name="Lacznosc" />
120 </RetryBoundary>
121 </div>
122 </section>
123 </div>
124 </div>
125 );
126}Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Czym jest graceful degradation w kontekście aplikacji React?
2. Czym różni się Promise.allSettled od Promise.all?
To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Edytor kodu
Zaimplementuj retry z backoff
- Układanie w pionie
Uporządkuj czasy oczekiwania w exponential backoff (base 1s):
- Edytor kodu
Zaimplementuj partial failure handling
- Układanie w pionie
Ułóż poprawną kolejność opakowywania (od zewnętrznego do wewnętrznego):
- Edytor kodu
Zaimplementuj SafeLazyLoader