Module 7: Real-World Usage
Fetching Data by Hand: fetch, Effects and Race Conditions
Loading, error and success states, what fetch does and doesn't throw, canceling stale requests, and why doing it all by hand gets painful.
Almost every real app talks to a server. Before reaching for a library, you should know how to do it yourself, because the library is solving exactly the problems you'll hit here. Once you've felt them, the tools make sense.
The three states#
A request is always in one of three situations: waiting, failed, or done. Your UI needs to handle all three, and it's easy to forget two.
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let ignore = false;
async function load() {
try {
const res = await fetch("https://jsonplaceholder.typicode.com/users");
if (!res.ok) throw new Error(`Request failed with status ${res.status}`);
const data = await res.json();
if (!ignore) setUsers(data);
} catch (err) {
if (!ignore) setError(err);
} finally {
if (!ignore) setLoading(false);
}
}
load();
return () => { ignore = true; };
}, []);
if (loading) return <p>Loading...</p>;
if (error) return <p>Something went wrong: {error.message}</p>;
return (
<ul>
{users.map((u) => <li key={u.id}>{u.name}</li>)}
</ul>
);
}
There's a lot packed in there, so let's take the parts one at a time.
fetch doesn't reject on HTTP errors#
This catches almost everyone. fetch only rejects when the request itself fails: network down, DNS error, CORS block. A 404 or a 500 is, from fetch's perspective, a perfectly successful round trip. It hands you a response with ok: false.
const res = await fetch("/api/missing"); // resolves, even though the server said 404
res.ok; // false
res.status; // 404
So you check res.ok yourself and throw if it's false. Without that line, your code tries to parse an error page as data and fails somewhere confusing.
Why not make the effect async#
useEffect(async () => { // don't
const data = await fetchStuff();
}, []);
An effect has to return either nothing or a cleanup function. An async function always returns a promise, so React would receive a promise where it expects a cleanup. Define an inner async function and call it, as we did above.
Race conditions#
The ignore flag deserves a proper explanation. Picture a page that shows a user by id, and the id is a prop that can change.
t=0 id = 1 ──► request A sent
t=1 id = 2 ──► request B sent
t=2 B returns → setUser(userB) ✔ correct
t=3 A returns → setUser(userA) ✘ screen now shows user 1 for id 2
Responses don't have to arrive in the order you sent them. When the id changes, the effect's cleanup runs, which flips ignore = true for request A. When A finally lands, it checks the flag and drops the result.
function UserProfile({ id }) {
const [user, setUser] = useState(null);
useEffect(() => {
let ignore = false;
setUser(null);
fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
.then((res) => res.json())
.then((data) => { if (!ignore) setUser(data); });
return () => { ignore = true; };
}, [id]); // re-run when id changes
return user ? <h2>{user.name}</h2> : <p>Loading...</p>;
}
Canceling the request for real#
ignore just throws away the answer. The request still goes out and still uses bandwidth. AbortController actually cancels it.
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then((res) => res.json())
.then(setResults)
.catch((err) => {
if (err.name === "AbortError") return; // expected when we cancel
setError(err);
});
return () => controller.abort();
}, [query]);
Aborting makes the pending fetch reject with an AbortError. That's not a real failure, so filter it out. Combine this with the useDebounce hook from module 4 and a search box behaves well: fewer requests, no stale results.
Where does the fetch belong?#
Not in the render body. It runs on every render, which would send a request, set state, render again, and loop forever. Also not in an event handler if you want the data on page load. A fetch triggered by a user action (a button click, a form submit) does go in the handler, no effect needed.
async function handleSubmit(e) {
e.preventDefault();
const res = await fetch("/api/orders", { method: "POST", body: JSON.stringify(order) });
if (!res.ok) { setError("Could not place order"); return; }
navigate("/thanks");
}
Rule: loading data to show on screen is an effect (or better, a library). Doing something because of a click is a handler.
StrictMode and the double request#
In development you'll see your request fire twice. That's StrictMode running setup, cleanup, setup. It's not a bug in your app. If the first request is aborted or ignored by the cleanup, the second one provides the data. Both requests in the Network tab is normal in dev and won't happen in production.
Extract a hook#
Once the pattern repeats, wrap it up:
function useFetch(url) {
const [state, setState] = useState({ data: null, error: null, loading: true });
useEffect(() => {
const controller = new AbortController();
setState({ data: null, error: null, loading: true });
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => setState({ data, error: null, loading: false }))
.catch((error) => {
if (error.name !== "AbortError") setState({ data: null, error, loading: false });
});
return () => controller.abort();
}, [url]);
return state;
}
What this approach can't do#
Here's what you'll run into as an app grows, and why a data library exists:
- No cache. Navigate away and back, and you fetch again from scratch, showing a spinner each time.
- No deduplication. Two components asking for the same URL send two requests.
- No retry. A flaky mobile connection means a permanent error screen.
- No background refresh. Data gets stale while the user keeps the tab open.
- No shared loading state. Mutating data on one screen doesn't update another screen showing the same data.
You could build all of that, and many people have, usually badly. That's the next lesson.
React 19's use with Suspense#
For completeness: React 19 has a use function that can read a promise, and a Suspense boundary shows a fallback while it's pending. It works, but it needs a promise that's created outside render and cached, otherwise you create a new promise every render. It's mainly intended for frameworks and libraries to build on, so for most client-side apps today, a library like TanStack Query is the practical route.
Where people get burned#
- Not checking
res.ok, then getting a confusing JSON parse error on a 404 page. - Marking the effect
async. - No cleanup, then seeing the wrong data when the id changes quickly.
- Forgetting
finallyand leavingloadingstuck ontrueafter an error. - Putting the fetch in the render body.
- Treating the duplicate request in dev as a bug and removing StrictMode.
- Building a query string by hand without
encodeURIComponent.
Practice#
- Build the
UserListcomponent againstjsonplaceholder.typicode.com/users. Then change the URL to a bad path and make sure the error state shows properly. - Make a
UserProfilewith Next and Previous buttons that change the id. Throttle the network to "Slow 3G" in DevTools and click quickly. Remove theignoreflag and observe the bug, then put it back. - Add
AbortControllerto a search-as-you-type box with the debounce hook. Watch cancelled requests in the Network tab. - Write
useFetchand use it in two different components. Note how many requests go out when both mount.