The problem with hand-written useEffect + fetch
Loading data from the server seems simple: a useEffect that runs fetch and
stores the result in state. It's the first thing we learn, and for an isolated
case it works:
function Profile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(`/api/users/${userId}`)
.then((r) => {
if (!r.ok) throw new Error("Error " + r.status);
return r.json();
})
.then((data) => { if (!cancelled) setUser(data); })
.catch((e) => { if (!cancelled) setError(e); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [userId]);
// ...render based on loading / error / user
}
Look at how much plumbing code there is for a single request, and it's still missing things. As the app grows, this pattern falls short because every screen has to reinvent:
- Cache: if two components request
/api/users/7, two requests are made. There's no shared memory of what's already been downloaded. - Deduplication: several simultaneous calls to the same URL should be merged into a single in-flight request.
- Revalidation: data expires. When is it requested again? On refocusing the window, on reconnecting the network, every so often?
- States:
isLoading,error,isFetching(background refetch), stale data while new data arrives... handling them by hand is fragile. - Cancellation and race conditions: if
userIdchanges quickly, a slow response can overwrite a fast one (hence thecancelledflag).
These problems don't belong to client state (what the user types or selects): they belong to server state, data that lives remotely, that we cache on the client and that can become outdated. Data fetching libraries are born to manage exactly that.
TanStack Query (React Query)
TanStack Query treats server state as a cached resource. The
central piece is the useQuery hook:
import { useQuery } from "@tanstack/react-query";
function Profile({ userId }) {
const { data, isLoading, error } = useQuery({
queryKey: ["user", userId],
queryFn: () =>
fetch(`/api/users/${userId}`).then((r) => r.json()),
staleTime: 60_000, // data "fresh" for 60 s
});
if (isLoading) return <p>Loading…</p>;
if (error) return <p>Something went wrong</p>;
return <h1>{data.name}</h1>;
}
The keys:
queryKey(["user", userId]): identifies the query in the cache. Two components with the same key share the same data and a single request is made (deduplication). IfuserIdchanges, the key changes and it's requested again.queryFn: the function that returns a promise with the data. You only worry about how to request, not when or how to cache.data/isLoading/error: the states already resolved for you. No manualuseStateoruseEffect.staleTime: how long the data is considered fresh. While it is, it isn't requested again; after that time it becomes stale and gets revalidated (for example on refocusing the window), showing meanwhile the old data.refetch: the query itself exposes arefetch()function to request again on demand (a "Refresh" button).
Everything you used to write by hand —cache, deduplication, revalidation, cancellation— comes out of the box.
SWR
SWR (from Vercel) follows the same philosophy with a more minimalist API. Its name comes from stale-while-revalidate: it shows the cached data (stale) instantly and, in parallel, revalidates in the background.
import useSWR from "swr";
const fetcher = (url) => fetch(url).then((r) => r.json());
function Profile({ userId }) {
const { data, error, isLoading } = useSWR(
`/api/users/${userId}`,
fetcher
);
if (isLoading) return <p>Loading…</p>;
if (error) return <p>Something went wrong</p>;
return <h1>{data.name}</h1>;
}
Here the cache key is the URL itself (first argument) and the fetcher is
the download function. SWR also deduplicates requests, revalidates on refocus
and reconnect, and exposes mutate() to update the cache.
Key idea: separate the server state (cached remote data → TanStack Query / SWR) from the client state (local UI →
useState, Zustand...). Don't put server data into a global store "by hand"; let a query library manage its cache and freshness.
(These libraries are installed separately; here we study them conceptually. In the validable exercises we keep using React's native hooks.)