React API Error Handling
Table of Contents + −
In the last lesson, React API Loading States, you showed a “Loading…” message while waiting for data. This lesson adds error handling, so a failed request shows a friendly message instead of a broken page.
🤔 Why do we need error handling?
A fetch can fail in more than one way, and each one leaves the user confused if you don’t handle it.
- The network is down, so the request never reaches the server.
- The server answers with a
404or500, so there’s no real data. - The user is offline, or on a flaky connection that times out.
- Without handling, your code tries to use missing data and the page crashes or goes blank.
So error handling is about catching those failures and telling the user in plain words. It turns a broken page into a calm “Something went wrong, please try again”.
🧩 Two kinds of failure to handle
Here’s a thing that surprises people. fetch treats two failures differently, so you have to handle both.
- A network failure, like being offline, makes
fetchthrow an error. Yourcatchblock catches it. - A bad status, like
404or500, does not throw.fetchsees it as a “successful” response that just happens to be an error. - So you must check the status yourself with
response.okand throw if it’s not okay. - Cover both, or some errors slip through.
fetch does not throw on 404 or 500
This trips up almost everyone. A 404 or 500 is still a response, so fetch does not reject. Your catch block won’t run for it unless you check response.ok and throw the error yourself.
💡 Error handling in action
Let’s put it all together. We add an error state, check response.ok, and catch any failure, all while keeping the loading state from before.
import { useState, useEffect } from "react";
function UserList() { const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // no error to start
useEffect(() => { async function loadUsers() { try { const response = await fetch("https://jsonplaceholder.typicode.com/users");
// check the status ourselves - fetch won't throw on 404/500 if (!response.ok) { throw new Error(`Request failed with status ${response.status}`); }
const data = await response.json(); setUsers(data); } catch (err) { setError(err.message); // save a message to show the user } finally { setLoading(false); } } loadUsers(); }, []);
if (loading) return <p>Loading...</p>; if (error) return <p>Something went wrong: {error}</p>;
return ( <ul> {users.map((user) => ( <li key={user.id}>{user.name}</li> ))} </ul> );}Follow how each failure is handled.
- We add
errorstate, starting atnull, meaning no error yet. - After the fetch,
if (!response.ok)checks the status. A404or500makes usthrow, which jumps tocatch. - A network failure also throws on its own, and the same
catchcatches it. - In
catch, we save the message intoerror. Infinally, we stop loading either way. - In the JSX, we check
loadingfirst, thenerror, then finally show the data.
Output
(if the request works:)Leanne GrahamErvin Howell...
(if it fails, for example the server is down:)Something went wrong: Request failed with status 500🚦 The three render branches
Notice the order of the checks in the JSX. It’s a clean, standard pattern you’ll reuse on every page that loads data.
- First check
loading. If still waiting, show the loader and stop. - Then check
error. If something failed, show the message and stop. - If neither, you have data, so show it.
- Get used to this shape, you’ll write it a lot.
🔁 Let the user try again
A failed request is less frustrating if the user can retry. You can add a button that runs the fetch again.
if (error) { return ( <div> <p>Something went wrong: {error}</p> <button onClick={() => window.location.reload()}>Try again</button> </div> );}A couple of notes on retrying.
- The simplest retry just reloads the page, which runs the fetch fresh.
- A nicer way is to move the fetch into its own function and call it again on click, without a full reload.
- Either way, a basic “Try again” button turns a frustrating failure into something the user can recover from.
⚠️ Common Mistakes
The biggest mistake is forgetting that fetch doesn’t throw on bad statuses, so you never check response.ok.
// ❌ a 404 sails through - response.json() may give an error page, not your dataconst response = await fetch(url);const data = await response.json();
// ✅ check the status first, throw if it's not okayconst response = await fetch(url);if (!response.ok) throw new Error(`Status ${response.status}`);const data = await response.json();Watch out for these.
- Don’t assume a response means success. Always check
response.okfor bad statuses. - Don’t leave the
catchempty. Save the error so you can show the user something. - Don’t forget the error branch in your JSX, or the error is caught but never shown.
✅ Best Practices
A few habits for solid error handling.
- Wrap your fetch in
try/catchto catch network failures. - Check
response.okandthrowon a bad status, so the samecatchhandles it. - Keep an
errorstate and show a clear, friendly message, not a raw stack trace. - Handle all three states in order: loading, then error, then the data.
- Offer a way to retry so a failure isn’t a dead end.
Friendly beats technical
The user doesn’t care about “status 500”. Show something calm and human like “We couldn’t load this right now. Please try again.” Keep the technical detail in the console for yourself.
🧩 What You’ve Learned
- ✅ Requests fail often, from network drops to
404and500responses - ✅
fetchthrows on network failures, but NOT on bad statuses like404or500 - ✅ Check
response.okandthrowyourself so a bad status reaches yourcatch - ✅ Keep an
errorstate and show a friendly message when something fails - ✅ Handle three branches in order: loading, then error, then success
- ✅ Give the user a way to retry instead of leaving a dead end
Check Your Knowledge
Test what you learned. Pick an answer for each question, then click Check.
- 1
Does fetch throw an error on a 404 or 500 response?
Why: fetch only rejects on network failures. A 404 or 500 is treated as a completed response, so you check response.ok and throw the error yourself.
- 2
What does response.ok tell you?
Why: response.ok is true when the status is a success code in the 200 range. If it's false, the request had a problem like 404 or 500.
- 3
In what order should you handle the render branches?
Why: Check loading first and show the loader, then check error and show the message, and only if neither, show the data. This is the standard data-loading pattern.
- 4
What should you show the user when a request fails?
Why: Show a calm, human message like 'We couldn't load this, please try again', and offer a retry. Keep the technical detail in the console for yourself.
🚀 What’s Next?
Now your component handles all three states cleanly: loading, error, and success. Next we’ll focus on that success case and look at the different ways to display the API data nicely on the screen.