React API Error Handling

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 404 or 500, 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 fetch throw an error. Your catch block catches it.
  • A bad status, like 404 or 500, does not throw. fetch sees it as a “successful” response that just happens to be an error.
  • So you must check the status yourself with response.ok and 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 error state, starting at null, meaning no error yet.
  • After the fetch, if (!response.ok) checks the status. A 404 or 500 makes us throw, which jumps to catch.
  • A network failure also throws on its own, and the same catch catches it.
  • In catch, we save the message into error. In finally, we stop loading either way.
  • In the JSX, we check loading first, then error, then finally show the data.

Output

(if the request works:)
Leanne Graham
Ervin 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 data
const response = await fetch(url);
const data = await response.json();
// ✅ check the status first, throw if it's not okay
const 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.ok for bad statuses.
  • Don’t leave the catch empty. 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/catch to catch network failures.
  • Check response.ok and throw on a bad status, so the same catch handles it.
  • Keep an error state 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 404 and 500 responses
  • fetch throws on network failures, but NOT on bad statuses like 404 or 500
  • ✅ Check response.ok and throw yourself so a bad status reaches your catch
  • ✅ Keep an error state 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. 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. 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. 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. 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.

React Displaying API Data

Share & Connect