Kurs JavaScript i React · Moduł 11: Pobieranie danych i API
Zaawansowana obsługa błędów
W tej lekcji4
W kosmosie rzeczy często nie idą zgodnie z planem: komunikacja może być przerwana, serwery mogą być przeciążone, dane mogą być uszkodzone. Do tej pory każdy błąd kończył się jednym komunikatem w stylu "Coś poszło nie tak". Użytkownik nie wie wtedy, czy ma się zalogować, poczekać, czy sprawdzić Wi-Fi. Dobra obsługa błędów to różnica między udaną misją a katastrofą. W tej lekcji zbudujemy cztery warstwy ochrony: czytelne komunikaty, Error Boundary, powiadomienia toast i ponawianie z rosnącym opóźnieniem.
Try/Catch/Finally - pełen wzorzec
Pierwsza warstwa zamienia kody statusu HTTP na zdania zrozumiałe dla człowieka. Instrukcja switch dobiera komunikat do kodu, a catch odróżnia błędy serwera od błędów sieci. Gdy w ogóle nie ma połączenia, fetch() odrzuca Promise z błędem typu TypeError:
1async function fetchWithFullErrorHandling(url) {
2 try {
3 // Próba pobrania danych
4 const response = await fetch(url);
5
6 if (!response.ok) {
7 // Różne komunikaty dla różnych kodów błędów
8 switch (response.status) {
9 case 400:
10 throw new Error('Nieprawidłowe zapytanie');
11 case 401:
12 throw new Error('Brak autoryzacji - zaloguj się');
13 case 403:
14 throw new Error('Brak dostępu do tego zasobu');
15 case 404:
16 throw new Error('Zasób nie został znaleziony');
17 case 429:
18 throw new Error('Zbyt wiele zapytań - spróbuj później');
19 case 500:
20 throw new Error('Błąd serwera - spróbuj później');
21 default:
22 throw new Error(`Nieznany błąd: ${response.status}`);
23 }
24 }
25
26 return await response.json();
27 } catch (err) {
28 // Obsługa błędów sieci (brak internetu, timeout)
29 if (err instanceof TypeError) {
30 throw new Error(
31 'Błąd sieci - sprawdź połączenie z internetem'
32 );
33 }
34 throw err; // Re-throw inne błędy
35 }
36}Nasze własne błędy z switch to zwykłe Error, a nie TypeError, więc catch przepuszcza je dalej bez zmian (throw err). Funkcja nadal zwraca dane albo rzuca błąd, zmieniła się tylko jakość komunikatu.
Komponent ErrorBoundary
Druga warstwa chroni przed błędem, który wysadziłby cały interfejs. Gdy komponent rzuci wyjątek podczas renderowania, React odmontowuje całe drzewo i użytkownik widzi białą stronę. Error Boundary to komponent, który łapie błędy renderowania w swoich dzieciach i pokazuje zapasowy widok. Nawet w React 19 da się go napisać tylko jako komponent klasowy, bo potrzebuje metod cyklu życia static getDerivedStateFromError (ustawia stan błędu) i componentDidCatch (np. loguje błąd):
1import React from 'react';
2
3class ErrorBoundary extends React.Component {
4 constructor(props) {
5 super(props);
6 this.state = { hasError: false, error: null };
7 }
8
9 static getDerivedStateFromError(error) {
10 return { hasError: true, error };
11 }
12
13 componentDidCatch(error, errorInfo) {
14 console.error('ErrorBoundary złapał błąd:', error);
15 console.error('Stack:', errorInfo.componentStack);
16 }
17
18 render() {
19 if (this.state.hasError) {
20 return (
21 <div className="error-boundary">
22 <h2>Coś poszło nie tak</h2>
23 <p>{this.state.error?.message}</p>
24 <button onClick={() => this.setState({
25 hasError: false, error: null
26 })}>
27 Spróbuj ponownie
28 </button>
29 </div>
30 );
31 }
32
33 return this.props.children;
34 }
35}
36
37// Użycie:
38function App() {
39 return (
40 <ErrorBoundary>
41 <SpaceDashboard />
42 </ErrorBoundary>
43 );
44}Gdy SpaceDashboard wybuchnie, reszta aplikacji przetrwa, a przycisk czyści stan i próbuje wyrenderować dzieci jeszcze raz. Bardzo ważne ograniczenie z dokumentacji React: Error Boundary nie łapie błędów w obsłudze zdarzeń ani w kodzie asynchronicznym, czyli np. odrzuconego fetch() w useEffect. Takie błędy obsługujesz przez try/catch i stan error. W praktyce polecam gotową paczkę react-error-boundary, żeby nie pisać tej klasy samodzielnie.
Powiadomienia Toast
Trzecia warstwa to komunikaty, które nie zasłaniają treści. Toast to krótki dymek w rogu ekranu, znikający po kilku sekundach. Żeby dowolny komponent mógł go wywołać, udostępniamy funkcję addToast przez Context:
1import React, { useState, createContext, useContext } from 'react';
2
3const ToastContext = createContext();
4
5function ToastProvider({ children }) {
6 const [toasts, setToasts] = useState([]);
7
8 const addToast = (message, type = 'error') => {
9 const id = Date.now();
10 setToasts(prev => [...prev, { id, message, type }]);
11 // Auto-usuwanie po 5 sekundach
12 setTimeout(() => {
13 setToasts(prev => prev.filter(t => t.id !== id));
14 }, 5000);
15 };
16
17 return (
18 <ToastContext.Provider value={{ addToast }}>
19 {children}
20 <div className="toast-container">
21 {toasts.map(toast => (
22 <div key={toast.id} className={`toast toast-${toast.type}`}>
23 {toast.message}
24 </div>
25 ))}
26 </div>
27 </ToastContext.Provider>
28 );
29}
30
31function useToast() {
32 return useContext(ToastContext);
33}Provider trzyma listę toastów w stanie, a setTimeout usuwa każdy po 5 sekundach. Hook useToast to tylko skrót do useContext. Teraz dowolny komponent wewnątrz providera może zgłosić sukces albo błąd:
1// Użycie w komponencie:
2function DataComponent() {
3 const { addToast } = useToast();
4
5 const fetchData = async () => {
6 try {
7 const response = await fetch('/api/data');
8 if (!response.ok) throw new Error('Błąd serwera');
9 const data = await response.json();
10 addToast('Dane pobrane pomyślnie!', 'success');
11 } catch (err) {
12 addToast(err.message, 'error');
13 }
14 };
15
16 return <button onClick={fetchData}>Pobierz dane</button>;
17}Komponent nie renderuje żadnego komunikatu sam, tylko zgłasza go wyżej. Drobiazg: Date.now() jako id może się powtórzyć przy dwóch toastach w tej samej milisekundzie, więc w produkcji użyj crypto.randomUUID() albo licznika.
Retry z exponential backoff
Czwarta warstwa naprawia błędy chwilowe. Gdy serwer jest przeciążony (429) albo ma awarię (5xx), natychmiastowe ponowienie tylko go dobija. Exponential backoff to czekanie coraz dłużej: 1 s, 2 s, 4 s. Opóźnienie liczymy jako Math.pow(2, attempt) * 1000:
1async function fetchWithRetry(url, maxRetries = 3) {
2 for (let attempt = 0; attempt <= maxRetries; attempt++) {
3 try {
4 const response = await fetch(url);
5
6 if (response.status === 429 || response.status >= 500) {
7 // Serwer przeciążony lub błąd serwera
8 if (attempt < maxRetries) {
9 // Exponential backoff: 1s, 2s, 4s
10 const delay = Math.pow(2, attempt) * 1000;
11 console.log(
12 `Próba ${attempt + 1} nie powiodła się. \
13Ponowna próba za ${delay}ms...`
14 );
15 await new Promise(resolve =>
16 setTimeout(resolve, delay)
17 );
18 continue;
19 }
20 }
21
22 if (!response.ok) {
23 throw new Error(`HTTP Error: ${response.status}`);
24 }
25
26 return await response.json();
27 } catch (err) {
28 if (attempt === maxRetries) {
29 throw new Error(
30 `Nie udało się po ${maxRetries + 1} próbach: ${err.message}`
31 );
32 }
33
34 const delay = Math.pow(2, attempt) * 1000;
35 await new Promise(resolve => setTimeout(resolve, delay));
36 }
37 }
38}Przy maxRetries = 3 funkcja próbuje łącznie cztery razy, a po ostatniej porażce rzuca błąd z liczbą prób. Zwróć uwagę, że catch ponawia każdy błąd, także 404, którego ponawianie nic nie da. W prawdziwym projekcie ponawiaj tylko błędy sieci, 429 i 5xx, a TanStack Query z poprzedniej lekcji zrobi to za Ciebie (domyślnie trzy ponowienia z backoffem).
Pamiętaj: dobry komunikat o błędzie mówi załodze, co się stało i co może zrobić dalej, więc nigdy nie zostawiaj jej w ciemności.
Kod do tej lekcji: App.jsx
1import React, { useState, createContext, useContext } from 'react';
2
3// Toast Context
4const ToastContext = createContext();
5
6function ToastProvider({ children }) {
7 const [toasts, setToasts] = useState([]);
8
9 const addToast = (message, type = 'error') => {
10 const id = Date.now();
11 setToasts(prev => [...prev, { id, message, type }]);
12 setTimeout(() => {
13 setToasts(prev => prev.filter(t => t.id !== id));
14 }, 4000);
15 };
16
17 const removeToast = (id) => {
18 setToasts(prev => prev.filter(t => t.id !== id));
19 };
20
21 return (
22 <ToastContext.Provider value={{ addToast }}>
23 {children}
24 <div className="toast-container">
25 {toasts.map(toast => (
26 <div key={toast.id} className={"toast toast-" + toast.type}>
27 <span>{toast.message}</span>
28 <button className="toast-close" onClick={() => removeToast(toast.id)}>x</button>
29 </div>
30 ))}
31 </div>
32 </ToastContext.Provider>
33 );
34}
35
36function useToast() {
37 return useContext(ToastContext);
38}
39
40// Symulacja roznych odpowiedzi serwera
41function simulateResponse(scenario) {
42 return new Promise((resolve, reject) => {
43 setTimeout(() => {
44 switch (scenario) {
45 case 'success':
46 resolve({ ok: true, status: 200, json: () => Promise.resolve({ data: 'Dane pobrane!' }) });
47 break;
48 case '404':
49 resolve({ ok: false, status: 404, statusText: 'Not Found' });
50 break;
51 case '500':
52 resolve({ ok: false, status: 500, statusText: 'Internal Server Error' });
53 break;
54 case 'network':
55 reject(new TypeError('Failed to fetch'));
56 break;
57 default:
58 resolve({ ok: true, status: 200 });
59 }
60 }, 1000);
61 });
62}
63
64function ErrorDemo() {
65 const { addToast } = useToast();
66 const [loading, setLoading] = useState(false);
67
68 const testScenario = async (scenario) => {
69 setLoading(true);
70 try {
71 const response = await simulateResponse(scenario);
72
73 if (!response.ok) {
74 switch (response.status) {
75 case 400: throw new Error('Nieprawidlowe zapytanie');
76 case 401: throw new Error('Brak autoryzacji');
77 case 404: throw new Error('Zasob nie znaleziony');
78 case 429: throw new Error('Zbyt wiele zapytan');
79 case 500: throw new Error('Blad serwera');
80 default: throw new Error(`Blad HTTP: ${response.status}`);
81 }
82 }
83
84 const data = await response.json();
85 addToast(data.data, 'success');
86 } catch (err) {
87 if (err instanceof TypeError) {
88 addToast('Blad sieci - sprawdz polaczenie', 'error');
89 } else {
90 addToast(err.message, 'error');
91 }
92 } finally {
93 setLoading(false);
94 }
95 };
96
97 return (
98 <div className="demo">
99 <h1>Obsluga Bledow - Demo</h1>
100 <p>Kliknij przycisk, aby zasymulowac rozne odpowiedzi serwera:</p>
101
102 <div className="button-grid">
103 <button className="btn success" onClick={() => testScenario('success')} disabled={loading}>
104 200 OK (Sukces)
105 </button>
106 <button className="btn warning" onClick={() => testScenario('404')} disabled={loading}>
107 404 Not Found
108 </button>
109 <button className="btn danger" onClick={() => testScenario('500')} disabled={loading}>
110 500 Server Error
111 </button>
112 <button className="btn danger" onClick={() => testScenario('network')} disabled={loading}>
113 Network Error
114 </button>
115 </div>
116
117 {loading && <p className="status">Wysylanie zapytania...</p>}
118 </div>
119 );
120}
121
122function App() {
123 return (
124 <ToastProvider>
125 <ErrorDemo />
126 </ToastProvider>
127 );
128}
129
130export default App;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 Error Boundary w React i kiedy się uruchamia?
Zadania praktyczne w grze
- Układanie w pionie
Ułóż rosnące opóźnienia w strategii exponential backoff (od najkrótszego do najdłuższego):
- Edytor kodu
System Toast
- Klikanie w kolejności
Kliknij elementy w kolejności bloku try/catch/finally:
- Klikanie w kolejności
Ułóż składnię obsługi błędów z async/await:
- Układanie w poziomie
Ułóż składnię warunku sprawdzającego kod statusu HTTP odpowiedzi: