Data Fetching Overview
Almost every real application needs to read data that lives on a server — a list of products, the current user, a search result. React itself has no opinion about how that data arrives; it only re-renders when state changes. That leaves you to choose how to fetch, where to store the result, and how to handle the loading, error, and stale states that come with talking to a network. This page maps out the landscape so you can pick the right approach before diving into the details on later pages.
Server state is not UI state
The single most useful idea in this section is that server state is fundamentally different from UI state. UI state — a toggled menu, a form’s draft value, the active tab — is owned by your component and is always correct because you set it. Server state is a cache of data that actually lives somewhere else, and that distinction drives every hard problem in data fetching.
| Property | UI state | Server state |
|---|---|---|
| Owner | Your component | A remote server |
| Freshness | Always current | Can go stale at any moment |
| Persistence | Lost on unmount | Persists across sessions |
| Shared by | One component (usually) | Many components and users |
| Needs | useState / useReducer | Caching, revalidation, dedup |
Once you accept that fetched data is a cache, you understand why tools exist purely for it: caches need invalidation, deduplication, background refetching, and retry logic. Trying to model all of that with raw useState is where the pain begins.
Fetching in effects
The classic approach is to fetch inside a useEffect and store the result in state. It works, and it is worth understanding, but it has well-known sharp edges.
import { useEffect, useState } from "react";
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
fetch(`https://api.example.com/users/${userId}`, {
signal: controller.signal,
})
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(setUser)
.catch((err) => {
if (err.name !== "AbortError") setError(err);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [userId]);
if (loading) return <p>Loading…</p>;
if (error) return <p>Could not load user: {error.message}</p>;
return <h1>{user.name}</h1>;
}
That is a lot of code for one request, and it still does not handle race conditions perfectly, caching, deduplication across components, or refetching when the window regains focus.
The biggest gotcha with effect-based fetching is the race condition: if
userIdchanges faster than requests resolve, a stale response can overwrite a newer one. TheAbortControllercleanup above mitigates it, but it is easy to forget — which is exactly why dedicated libraries exist.
Data-router loaders
If you use React Router’s data APIs, you can move fetching out of components and into route loaders that run before the route renders. The router waits for the data, so your component receives it already loaded — no loading flicker inside the component itself.
import { useLoaderData } from "react-router-dom";
export async function userLoader({ params }) {
const res = await fetch(`https://api.example.com/users/${params.id}`);
if (!res.ok) throw new Response("Not found", { status: res.status });
return res.json();
}
function UserRoute() {
const user = useLoaderData();
return <h1>{user.name}</h1>;
}
Loaders give you parallel fetching, declarative error boundaries, and a clean separation between routing and rendering. They are excellent for page-level data, but they are not a general client cache — you still want a library for fine-grained, component-level server state.
Dedicated data libraries
For most applications the recommended path is a library built specifically for server state. The two dominant choices are TanStack Query and SWR. Both turn the verbose effect above into a single hook and add caching, deduplication, background revalidation, and retries for free.
import { useQuery } from "@tanstack/react-query";
function UserProfile({ userId }) {
const { data, error, isPending } = useQuery({
queryKey: ["user", userId],
queryFn: () =>
fetch(`https://api.example.com/users/${userId}`).then((r) => r.json()),
});
if (isPending) return <p>Loading…</p>;
if (error) return <p>Error: {error.message}</p>;
return <h1>{data.name}</h1>;
}
Here is how the approaches compare at a glance:
| Approach | Caching | Dedup / revalidation | Best for |
|---|---|---|---|
useEffect + fetch | None | Manual | Learning, one-off requests |
| Router loaders | Per-navigation | Built into routing | Page-level data |
| TanStack Query | Powerful, configurable | Automatic | Complex apps, mutations |
| SWR | Lightweight | Automatic | Simpler read-heavy apps |
Best Practices
- Treat fetched data as a cache of server state, not as ordinary UI state.
- Reach for
useEffect+fetchonly for trivial cases or while learning; always include anAbortControllerto avoid race conditions. - Use route loaders for page-level data so you fetch before render and avoid in-component loading flicker.
- Prefer TanStack Query or SWR for component-level server state — they solve caching, dedup, and revalidation so you do not reinvent them.
- Always render explicit loading and error states; a fetch that can fail will eventually fail.
- Pick one primary data layer per app rather than mixing several caching strategies.