Fetching Data in React: Loading, Error, and Server State
Learn to fetch data from an HTTP API inside a React app using fetch, model the full lifecycle of a request (loading, error, empty, retry, timeout, cancellation), and understand the server-state problems — caching, refetching, race conditions, deduplication — that motivate libraries like TanStack Query.
Fetching data sounds like a one-line fetch() call, but a production-quality data-fetching component has to correctly handle far more than the happy path: what renders while the request is in flight, what renders if it fails, what renders if it succeeds but returns nothing, what happens if the component unmounts mid-request, and what happens if a second request starts before the first one finishes. This lesson works through each of those states and then explains why most real apps eventually reach for a server-state library rather than hand-rolling all of it with useEffect.
fetch, HTTP methods, headers, and JSON
The Fetch API is the browser's built-in way to make HTTP requests. fetch(url) defaults to a GET request and returns a Promise that resolves to a Response object once the server has responded (note: it resolves even for HTTP error statuses like 404 or 500 — a fetch Promise only rejects on network-level failures, not on 'bad' status codes, which is a common source of bugs). To read the body as JSON you call response.json(), which itself returns a Promise, so a typical flow is const res = await fetch(url); const data = await res.json();.
For methods other than GET — POST, PUT, PATCH, DELETE — you pass a second options object: { method: 'POST', headers: {...}, body: JSON.stringify(payload) }. The Content-Type: application/json header tells the server how to parse the body, and you must call JSON.stringify on any object body yourself — fetch does not serialize it for you. PUT typically replaces a whole resource, PATCH updates part of one, and DELETE removes it; which one a given API expects is a backend contract, not something fetch enforces.
Because fetch resolves on HTTP error statuses, you must explicitly check response.ok (true for 2xx statuses) or response.status and throw an error yourself if the request failed at the application level — this is exactly the gap that later sections on error handling build on.
Modeling loading, error, and empty states
Every component that fetches data needs at least three pieces of state beyond the data itself: a loading flag (is a request currently in flight), an error value (did the last request fail, and with what message), and an awareness of the empty case (the request succeeded but returned zero results, which is not an error and should render differently from both a loading spinner and an error message). Skipping any of these produces a component that either flashes stale data, shows nothing on failure, or can't distinguish 'still loading' from 'loaded, and there's nothing here.'
A common pattern is a small discriminated union or a set of derived booleans: status: 'idle' | 'loading' | 'success' | 'error' alongside the data and error fields. This makes the render logic exhaustive and easy to reason about — you render a spinner for 'loading', an error banner for 'error', an empty state for 'success' with an empty array, and the real content for 'success' with data — rather than juggling independent loading/error booleans that can (incorrectly) both be true or both be false at once.
Retry means giving the user (or the code) a way to re-attempt a failed request — typically a button in the error UI that re-runs the same fetch function, resetting status back to 'loading'. A timeout means giving up on a request that's taking too long, even though the network hasn't technically failed — implemented by racing the fetch against a timer, or by using AbortController with a scheduled abort() call, and surfacing that as its own kind of error ('request timed out') distinct from a network failure or a 4xx/5xx response.
Cancellation and race conditions
Cancellation means stopping an in-flight request that's no longer needed — most commonly because the component that started it has unmounted, or because its inputs changed and a newer request has superseded it. The AbortController API is the standard mechanism: you create a controller, pass controller.signal into fetch's options, and call controller.abort() to cancel; fetch then rejects with an AbortError you can distinguish from a real failure. Inside a useEffect, the cleanup function is exactly where you call abort(), so navigating away or re-running the effect (because a dependency changed) doesn't leave a stale request finishing in the background.
A race condition happens when two requests for the same data are in flight at once and resolve out of order — for example, a search box fires a fetch for 'rea', then the user types one more character and a second fetch for 'react' fires; if the first ('rea') request happens to resolve after the second ('react') one, its stale results overwrite the newer, more relevant ones on screen. This is a real, frequently-shipped bug, not a hypothetical: any input-driven fetch (search-as-you-type, filters, rapidly changing route params) is susceptible.
The fix is either cancellation (abort the previous request whenever a new one starts, using the AbortController pattern above) or an 'ignore stale results' guard — capturing a local flag or request id inside the effect and checking it hasn't gone stale before calling setState when the response arrives. Request deduplication is a related idea: if two parts of the UI ask for the exact same data (same URL, same params) at the same moment, a well-behaved data layer sends one actual network request and gives both callers the same result, instead of firing the request twice.
Pagination, caching, refetching, and server-state libraries
Pagination fetches data in fixed-size pages (?page=2&pageSize=20) with explicit next/previous controls, while infinite scrolling fetches the next page automatically as the user nears the bottom of the list (typically detected with an IntersectionObserver on a sentinel element) and appends it to what's already rendered rather than replacing it. Both need to track which page(s) have been loaded and whether more remain, usually via a hasMore or totalCount field the API returns.
Caching means keeping a previously-fetched result around so a later request for the same data doesn't have to hit the network again — this trades staleness risk for speed. Refetching is deliberately re-running a fetch to get fresh data (on window refocus, on a timer, or after a mutation), and cache invalidation is the harder problem of knowing precisely when cached data is no longer trustworthy and must be refetched — for example, invalidating the 'list of todos' cache the moment a todo is created, updated, or deleted, so the list doesn't silently show stale data.
Data that lives on a server and is merely mirrored into your frontend — a list of products, a user's profile, search results — is called server state, and it behaves fundamentally differently from ordinary UI state (like whether a dropdown is open): server state can go stale behind your back, can fail to load, is often shared across multiple components, and needs caching and invalidation logic. Because correctly handling loading/error/retry/cache/refetch/dedup/race-conditions by hand in every component is a lot of repeated, easy-to-get-wrong code, most production React apps adopt a server-state library — TanStack Query (formerly React Query) is the most widely used — which handles all of the above behind a single hook like useQuery, so component code goes back to being about rendering, not request plumbing.
A reusable fetch hook with loading, error, and cancellation
The AbortController is created fresh inside the effect and aborted in the cleanup function, so a re-run (url changes) or an unmount cancels the previous, now-irrelevant request. The AbortError branch is deliberately ignored rather than surfaced as a real error, since it represents an intentional cancellation, not a failure.
import { useEffect, useState } from "react";
type FetchState<T> =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "success"; data: T };
function useFetch<T>(url: string): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({ status: "loading" });
useEffect(() => {
const controller = new AbortController();
setState({ status: "loading" });
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json() as Promise<T>;
})
.then((data) => setState({ status: "success", data }))
.catch((err: unknown) => {
if (err instanceof DOMException && err.name === "AbortError") return;
setState({ status: "error", error: (err as Error).message });
});
return () => controller.abort();
}, [url]);
return state;
}Code Examples
Each of the four states — loading, error, empty success, and populated success — gets its own explicit branch, so the UI never confuses 'still fetching' with 'fetched, but nothing came back.' Expected result: A spinner while in flight, an error banner with retry on failure, 'No products found.' on an empty array, or the actual list.
The Content-Type header and JSON.stringify are both required for the server to correctly parse the body. Because fetch does not reject on a 4xx/5xx response, res.ok is checked explicitly and an Error is thrown so callers can use ordinary try/catch or .catch() handling.
if (!res.ok) {... throw new Error(...) } Common mistake: Assuming a 404 or 500 response will land in a.catch() block automatically — it won't, because fetch only rejects on network failures, not HTTP error statuses.
function ProductList() {
const state = useFetch<Product[]>("/api/products");
if (state.status === "loading") return <Spinner />;
if (state.status === "error") {
return (
<div>
<p>Couldn't load products: {state.error}</p>
<button onClick={() => window.location.reload()}>Retry</button>
</div>
);
}
if (state.data.length === 0) return <p>No products found.</p>;
return (
<ul>
{state.data.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}async function createProduct(payload: { name: string; price: number }) {
const res = await fetch("/api/products", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.message ?? `Failed with status ${res.status}`);
}
return res.json() as Promise<Product>;
}A race condition from out-of-order responses
Without cancellation or a staleness check, a slower earlier request can resolve after a faster later one and clobber the correct, newer results.
t0: user types 'rea' -> fetch A sent
t1: user types 'react' -> fetch B sent
t2: fetch B resolves -> UI shows results for 'react'
t3: fetch A resolves late -> UI overwritten with stale results for 'rea' (BUG)
Fix: abort A when B starts, or ignore A's result if it's no longer the latest request.Important Rules
Always check response.ok (or response.status) explicitly — fetch's Promise resolves, not rejects, for HTTP error statuses like 404 and 500.
Model loading, error, empty, and success as distinct, mutually exclusive states rather than independent booleans that can drift out of sync.
Cancel in-flight requests (AbortController) in a useEffect's cleanup function whenever the request's inputs change or the component unmounts.
Treat data fetched from a server as server state — expect it to go stale, plan explicit refetch/invalidate points, and don't just fetch it once and forget it.
Reach for a server-state library like TanStack Query once an app has more than a couple of fetch-driven components, rather than re-implementing caching, retry, and deduplication by hand each time.
Where this shows up
A search-as-you-type product filter is one of the most common places race conditions and cancellation actually bite: every keystroke can fire a new request, and without AbortController-based cancellation, users occasionally see search results for text they've already deleted, flickering back in a moment after they typed something new. This is exactly the class of problem that pushes most teams toward TanStack Query, since it handles request cancellation, deduplication, and stale-response discarding for every query automatically.
fetch(url).then((res) => res.json()).then(setData);
fetch(url).then((res) => { if (!res.ok) throw new Error(Request failed: ${res.status}); return res.json(); }).then(setData).catch(handleError);
The left version silently treats a 404 or 500 response as success and tries to parse whatever error body the server sent as if it were valid data, instead of surfacing the failure to an error state and a .catch handler.
[true-false] The Promise returned by fetch() rejects automatically when the server responds with a 404 or 500 status. A. True B. False
B. False — fetch only rejects on network-level failures (DNS failure, no connection, CORS block, etc.). A 404 or 500 is still a successful HTTP exchange as far as fetch is concerned, so you must check response.ok yourself.
[multiple-choice] What API is the standard way to cancel an in-flight fetch request? A. clearTimeout B. AbortController C. Promise.race D. res.cancel()
B. AbortController — AbortController provides a signal you pass into fetch's options; calling controller.abort() cancels the request and causes the fetch Promise to reject with an AbortError.
[multiple-choice] Why do most production React apps eventually adopt a server-state library like TanStack Query? A. Because fetch is deprecated and no longer works in modern browsers B. Because it handles caching, refetching, deduplication, and race conditions that are repetitive and error-prone to hand-roll in every component C. Because it replaces the need for any backend API D. Because useEffect cannot make network requests at all
B. Because it handles caching, refetching, deduplication, and race conditions that are repetitive and error-prone to hand-roll in every component — Server state has recurring needs — loading/error handling, caching, invalidation, deduplication, race-condition safety — that are easy to get wrong when reimplemented per-component; server-state libraries centralize that logic behind a single hook.
Extend the useFetch hook shown above to add a 5-second timeout: if the request hasn't resolved within 5 seconds, abort it and set state to an error of 'Request timed out.' useEffect(() => { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); setState({ status: "loading" }); fetch(url, { signal: controller.signal }).then((res) => { if (!res.ok) throw new Error(Failed: ${res.status}); return res.json(); }).then((data) => setState({ status: "success", data })).catch((err: unknown) => { if (err instanceof DOMException && err.name === "AbortError") { setState({ status: "error", error: "Request timed out." }); return; } setState({ status: "error", error: (err as Error).message }); }).finally(() => clearTimeout(timeoutId)); return () => { controller.abort(); clearTimeout(timeoutId); }; }, [url]);
Use setTimeout to call controller.abort() after 5000ms, started alongside the fetch call, and clear that timeout in the effect's cleanup (or once the fetch settles) so it doesn't fire after a fast, successful response.