I10 · Part 2 · Intermediate
Data fetching patterns
Every bug in hand-rolled fetching is one of five, and they are all solved problems.
06On this page
The naive version, and what is wrong with it
useEffect(() => {
fetch("/api/user/" + id)
.then((r) => r.json())
.then(setUser);
}, [id]);
Five separate bugs, in roughly the order they will bite you.
1. No error handling — and fetch does not throw on 404
const res = await fetch(url);
if (!res.ok) throw new Error("HTTP " + res.status); // you must check
return res.json();
fetch only rejects on a network failure. A 500 resolves happily,
and res.json() then throws a parse error on the HTML error page,
giving you a confusing message about an unexpected token.
2. The race condition
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then((r) => r.json())
.then(setUser)
.catch((e) => { if (e.name !== "AbortError") setError(e); });
return () => controller.abort();
}, [url]);
Change id from 1 to 2 quickly and both requests are in flight. If
the first is slower it resolves last and overwrites user 2 with user 1. The
screen shows the wrong person and nothing looks broken. This is the bug that
only appears on bad connections, which is to say on your users' connections.
3. No loading or error state
Rendering user.name before the request finishes throws. Every
fetch needs at least three states, and the empty result is a fourth.
4. No cache, no deduplication
Three components asking for the same user make three requests. Navigate away and back and it refetches from scratch, showing a spinner for data you had two seconds ago.
5. The waterfall
// ✗ sequential: 300ms + 300ms
const user = await getUser(id);
const posts = await getPosts(id);
// ✓ parallel: 300ms
const [user, posts] = await Promise.all([getUser(id), getPosts(id)]);
The component version of this is worse: a parent fetches, renders, and only then does the child discover what it needs. Each level of the tree adds a round trip. Hoisting the requests, or using a router loader, collapses them.
A hook that handles four of the five
function useFetch(url) {
const [state, setState] = useState({ status: "idle" });
useEffect(() => {
if (!url) return;
const controller = new AbortController();
setState({ status: "loading" });
fetch(url, { signal: controller.signal })
.then((r) => {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
})
.then((data) => setState({ status: "ready", data }))
.catch((error) => {
if (error.name !== "AbortError") setState({ status: "error", error });
});
return () => controller.abort();
}, [url]);
return state;
}
One status field rather than three booleans, so the impossible combinations cannot occur. What it still does not do is cache — and caching is where hand-rolling stops being worth it.
The line where you should stop writing this yourself
Caching, deduplicating in-flight requests, revalidating on focus, invalidating after a mutation, pagination, retries with backoff, optimistic updates and rollback. Each is a day of work and a source of subtle bugs. TanStack Query is roughly 12 KB and does all of it — and in a framework, the loader or Server Component does it before React is involved at all.
Stale-while-revalidate
The pattern behind every good data library, and worth understanding on its own: show the cached value immediately, refetch in the background, replace it if it changed. The screen is never blank for data you already had, and it is never wrong for long.
It changes what "loading" means. There is a first load, where there is nothing to show, and there is a revalidation, where there is stale data on screen. The first deserves a skeleton; the second deserves a subtle indicator at most. Showing a full spinner for a background refresh is the most common way this gets built wrong.
Mutations, and the two ways to update
// pessimistic: wait, then reflect the truth
await save(item);
refetch();
// optimistic: assume success, roll back if wrong
const previous = items;
setItems(items.map((i) => (i.id === id ? { ...i, done: true } : i)));
try {
await save({ id, done: true });
} catch {
setItems(previous); // the part people forget
showError();
}
Optimistic updates make an app feel instant, and the rollback is what makes them honest. An optimistic update without one shows the user a change that never happened — and they find out when they refresh.
Fetching on the server instead
// Next.js Server Component — no effect, no loading state, no waterfall
export default async function Page({ params }) {
const user = await getUser(params.id);
return <Profile user={user} />;
}
Where the framework allows it, this is strictly better: the request happens next to the database, the browser gets HTML with the data already in it, and none of the five bugs above can occur because there is no client-side fetch. Client fetching then handles only what is genuinely interactive — polling, infinite scroll, anything after a user action. Server Components covers this.
Choosing
| Situation | Use |
|---|---|
| Framework with loaders or Server Components | Fetch there. Nothing beats not doing it on the client. |
| SPA, more than a couple of screens | TanStack Query or SWR |
| One request, one screen, a prototype | A useFetch like the one above — with the abort |
| GraphQL | Apollo or urql; the same cache ideas, schema-aware |
Interview answer, one sentence
"Fetching in an effect gives you five problems: no error handling because
fetch does not throw on 4xx, a race when the inputs change, no
shared cache, no deduplication, and waterfalls as each level discovers what
it needs. A data library solves all of them, and fetching on the server
avoids them entirely."
Opens in the editor: write it, run it, and check it against real tests.
All 66 for this level →Finished reading?
0 of 57 chapters read in this series