How to Fetch API Data Safely in React: Loading, Errors and Race Conditions
Fix the 4 hidden bugs in fetch-in-useEffect: loading and error states, response.ok, AbortController and race conditions.
Here's how most React tutorials fetch data:
function User({ id }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${id}`)
.then((res) => res.json())
.then(setUser);
}, [id]);
return <h2>{user.name}</h2>;
}Short and readable, and it has four bugs. Each one shows up in production, usually on a slow phone connection, and usually in front of a customer. Let's fix them one at a time.
Bug 1: it crashes before the data arrives
On the first render, user is null, so user.name throws: "Cannot read properties of null". The component needs to know it's loading.
Bug 2: errors vanish
If the request fails, nothing tells the user. They stare at a blank space forever.
Bug 3: fetch doesn't treat 404 or 500 as an error
This one surprises almost everyone. fetch only rejects when the request can't happen at all, for example when the network is down. A 404 or 500 is a successful delivery of a bad answer.
It's like a courier who delivers a letter saying "we're closed". The delivery succeeded; the news is bad. You have to open the letter and check:
const res = await fetch(url);
if (!res.ok) throw new Error(`Request failed: ${res.status}`);res.ok is true for statuses 200 to 299. Without this check, your code calls res.json() on an error page and fails with a confusing parsing error instead.
Bug 4: the race condition
The user clicks user 1, then quickly user 2. Two requests are now in flight. If the first one is slower, it arrives last and overwrites user 2's data. The screen says "User 2" in the heading and shows user 1's details.
Think of two pizzas ordered five minutes apart. The second order changed your mind, but the first pizza turned up late, and now that's dinner.
The safe version
import { useEffect, useState } from "react";
function User({ id }) {
const [user, setUser] = useState(null);
const [status, setStatus] = useState("loading"); // "loading" | "success" | "error"
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
setStatus("loading");
async function load() {
try {
const res = await fetch(`/api/users/${id}`, { signal: controller.signal });
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
const data = await res.json();
setUser(data);
setStatus("success");
} catch (err) {
if (err.name === "AbortError") return; // we cancelled it on purpose
setError(err);
setStatus("error");
}
}
load();
return () => controller.abort(); // cancel when id changes or on unmount
}, [id]);
if (status === "loading") return <p>Loading…</p>;
if (status === "error") return <p role="alert">Couldn't load this user. {error.message}</p>;
return <h2>{user.name}</h2>;
}What changed:
- A
statusvalue decides what to show, so the component never touches data that isn't there yet (bug 1) and always shows something when it fails (bug 2). res.okcheck turns 404 and 500 into real errors (bug 3).AbortControllercancels the old request whenidchanges. The cleanup function returned from the effect runs before the next effect, so the slow request for user 1 is aborted and can never overwrite user 2 (bug 4). It also stops updates after the component unmounts.- Ignoring
AbortError, because a cancelled request isn't a failure the user needs to hear about.
Why one status instead of separate isLoading and isError booleans? Two booleans allow impossible combinations like "loading and error at once". One status value can only be one thing.
Make it reusable
You'll write this pattern for every API call, so put it in a custom hook:
import { useEffect, useState } from "react";
export function useFetch(url) {
const [state, setState] = useState({ status: "loading", data: null, error: null });
useEffect(() => {
if (!url) return;
const controller = new AbortController();
setState({ status: "loading", data: null, error: null });
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json();
})
.then((data) => setState({ status: "success", data, error: null }))
.catch((error) => {
if (error.name !== "AbortError") setState({ status: "error", data: null, error });
});
return () => controller.abort();
}, [url]);
return state;
}Now components stay short:
function User({ id }) {
const { status, data, error } = useFetch(`/api/users/${id}`);
if (status === "loading") return <p>Loading…</p>;
if (status === "error") return <p role="alert">{error.message}</p>;
return <h2>{data.name}</h2>;
}"Why does it fetch twice?"
In development, React's StrictMode mounts your component, unmounts it, and mounts it again, on purpose. It's checking that your effect cleans up after itself. With the AbortController cleanup, the first request is cancelled and you'll see it marked "(canceled)" in the browser's Network tab. In production it runs once.
If you see two completed requests in development, your cleanup is missing.
Debugging what the API actually sent
When res.json() fails or the data looks wrong, look at the raw response. Copy it from the Network tab and paste it into the JSON formatter to read it properly and see exactly where invalid JSON breaks.
When to stop writing this yourself
The pattern above is right for learning and for small apps. Real apps soon need more: caching so revisiting a page is instant, retries when the network blips, refetching when the window regains focus, and sharing one request between components.
That's what TanStack Query and SWR do. With TanStack Query the whole thing becomes:
import { useQuery } from "@tanstack/react-query";
function User({ id }) {
const { isPending, isError, data, error } = useQuery({
queryKey: ["user", id],
queryFn: async ({ signal }) => {
const res = await fetch(`/api/users/${id}`, { signal });
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json();
},
});
if (isPending) return <p>Loading…</p>;
if (isError) return <p role="alert">{error.message}</p>;
return <h2>{data.name}</h2>;
}The res.ok check is still yours to write, because fetch still doesn't throw on 404. The library handles the rest: caching, deduplication, cancellation and race conditions. If you use a framework like Next.js, also look at its own data-loading features before reaching for effects.
What goes wrong
- Forgetting the
res.okcheck. The single most common fetch bug. - Setting state from a stale response. Always clean up with
AbortController(or anignoreflag) when the input can change. - Putting an object in the dependency array.
useEffect(..., [options])with a new object each render loops forever. Depend on primitive values likeidorurl. More in useEffect dependencies and infinite loops.
In one line: track loading and error with one status, check res.ok because fetch won't, cancel old requests with AbortController in the cleanup, and reach for TanStack Query or SWR once your app needs caching.
Keep reading
- React useEffect Explained: Dependencies, Cleanup and Infinite LoopsFrontend Development, 5 min read
- JavaScript Closures Explained with Practical React ExamplesFrontend Development, 5 min read
- Why It's Time to Move Your Website Off WordPress (and Why Next.js Wins for Static Sites)Frontend Development, 11 min read
New games, tools and tutorials
About one short email a week. No spam, one-click unsubscribe.