JavaScript and React course Β· Module 11: Data Fetching and APIs
Advanced Error Handling
In this lesson4
In space, things often don't go according to plan: communication can be interrupted, servers can be overloaded, data can be corrupted. Until now every error ended with a single message along the lines of "Something went wrong". The user then doesn't know whether to log in, wait or check the Wi-Fi. Good error handling is the difference between a successful mission and a disaster. In this lesson we'll build four layers of protection: clear messages, an Error Boundary, toast notifications and retrying with a growing delay.
Try/Catch/Finally - The Full Pattern
The first layer turns HTTP status codes into sentences a human understands. A switch statement picks the message for each code, and catch tells server errors apart from network errors. When there's no connection at all, fetch() rejects its Promise with a TypeError:
1async function fetchWithFullErrorHandling(url) {
2 try {
3 // Attempt to fetch data
4 const response = await fetch(url);
5
6 if (!response.ok) {
7 // Different messages for different error codes
8 switch (response.status) {
9 case 400:
10 throw new Error('Invalid request');
11 case 401:
12 throw new Error('Unauthorized - please log in');
13 case 403:
14 throw new Error('Access denied to this resource');
15 case 404:
16 throw new Error('Resource not found');
17 case 429:
18 throw new Error('Too many requests - try again later');
19 case 500:
20 throw new Error('Server error - try again later');
21 default:
22 throw new Error(`Unknown error: ${response.status}`);
23 }
24 }
25
26 return await response.json();
27 } catch (err) {
28 // Handle network errors (no internet, timeout)
29 if (err instanceof TypeError) {
30 throw new Error(
31 'Network error - check your internet connection'
32 );
33 }
34 throw err; // Re-throw other errors
35 }
36}Our own errors from the switch are plain Error objects, not TypeError, so catch passes them on unchanged (throw err). The function still returns data or throws an error, only the quality of the message has changed.
ErrorBoundary Component
The second layer protects against an error that would blow up the whole interface. When a component throws during rendering, React unmounts the whole tree and the user sees a blank page. An Error Boundary is a component that catches rendering errors in its children and shows a fallback view. Even in React 19 it can only be written as a class component, because it needs the lifecycle methods static getDerivedStateFromError (sets the error state) and componentDidCatch (for example, logs the error):
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 caught an error:', 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>Something went wrong</h2>
23 <p>{this.state.error?.message}</p>
24 <button onClick={() => this.setState({
25 hasError: false, error: null
26 })}>
27 Try again
28 </button>
29 </div>
30 );
31 }
32
33 return this.props.children;
34 }
35}
36
37// Usage:
38function App() {
39 return (
40 <ErrorBoundary>
41 <SpaceDashboard />
42 </ErrorBoundary>
43 );
44}When SpaceDashboard blows up, the rest of the app survives, and the button clears the state and tries to render the children again. A very important limitation from the React docs: an Error Boundary does not catch errors in event handlers or in asynchronous code, such as a rejected fetch() inside useEffect. You handle those with try/catch and an error state. In practice I recommend the ready-made react-error-boundary package so you don't have to write this class yourself.
Toast Notifications
The third layer is messages that don't cover the content. A toast is a short bubble in the corner of the screen that disappears after a few seconds. So that any component can trigger one, we expose an addToast function through 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-remove after 5 seconds
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}The provider keeps the list of toasts in state, and setTimeout removes each one after 5 seconds. The useToast hook is just a shortcut to useContext. Now any component inside the provider can report a success or an error:
1// Usage in a component:
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('Server error');
9 const data = await response.json();
10 addToast('Data fetched successfully!', 'success');
11 } catch (err) {
12 addToast(err.message, 'error');
13 }
14 };
15
16 return <button onClick={fetchData}>Fetch data</button>;
17}The component doesn't render any message itself, it only reports it upward. A small detail: Date.now() as an id can repeat for two toasts in the same millisecond, so in production use crypto.randomUUID() or a counter.
Retry with Exponential Backoff
The fourth layer fixes temporary errors. When the server is overloaded (429) or failing (5xx), retrying immediately only makes things worse. Exponential backoff means waiting longer and longer: 1 s, 2 s, 4 s. We compute the delay as 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 // Server overloaded or server error
8 if (attempt < maxRetries) {
9 // Exponential backoff: 1s, 2s, 4s
10 const delay = Math.pow(2, attempt) * 1000;
11 console.log(
12 `Attempt ${attempt + 1} failed. \
13Retrying in ${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 `Failed after ${maxRetries + 1} attempts: ${err.message}`
31 );
32 }
33
34 const delay = Math.pow(2, attempt) * 1000;
35 await new Promise(resolve => setTimeout(resolve, delay));
36 }
37 }
38}With maxRetries = 3 the function tries four times in total, and after the last failure it throws an error with the number of attempts. Note that catch retries every error, including a 404, where retrying achieves nothing. In a real project retry only network errors, 429 and 5xx, and TanStack Query from the previous lesson will do it for you (three retries with backoff by default).
Remember: a good error message tells the crew what happened and what they can do next, so never leave them in the dark.
Code for this lesson: 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// Simulation of different server responses
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: 'Data fetched!' }) });
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('Invalid request');
76 case 401: throw new Error('Not authorized');
77 case 404: throw new Error('Resource not found');
78 case 429: throw new Error('Too many requests');
79 case 500: throw new Error('Server error');
80 default: throw new Error(`HTTP error: ${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('Network error - check your connection', '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>Error Handling - Demo</h1>
100 <p>Click a button to simulate different server responses:</p>
101
102 <div className="button-grid">
103 <button className="btn success" onClick={() => testScenario('success')} disabled={loading}>
104 200 OK (Success)
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">Sending request...</p>}
118 </div>
119 );
120}
121
122function App() {
123 return (
124 <ToastProvider>
125 <ErrorDemo />
126 </ToastProvider>
127 );
128}
129
130export default App;Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What is an Error Boundary in React and when does it activate?
Hands-on tasks in the game
- Vertical ordering
Arrange the increasing delays in the exponential backoff strategy (from shortest to longest):
- Code editor
Toast System
- Click in order
Click the elements in order of the try/catch/finally block:
- Click in order
Arrange the error handling syntax with async/await:
- Horizontal ordering
Arrange the syntax for a condition that checks the HTTP response status code: