TanStack Query vs useEffect for Data Fetching in React
Fetching in useEffect looks simple until you need loading states, errors, caching, race conditions, refetching and mutations. What TanStack Query handles for you, side-by-side code, mutations with invalidation, and when server components or plain useEffect are still the right choice.
The first way everyone learns to fetch data in React:
const [user, setUser] = useState(null)
useEffect(() => {
fetch(`/api/users/${id}`).then(r => r.json()).then(setUser)
}, [id])
It works in a demo. In a real app, it quietly lacks about ten things. TanStack Query (formerly React Query) is a library that provides them. (useEffect explained)
What the naive version is missing
- Loading state — what to show before data arrives.
- Error state — what if the request fails (or returns 500)?
- Race conditions —
idchanges quickly; a slow old response arrives last and overwrites the new one. - Caching — navigate away and back, and it fetches again with a spinner.
- Deduplication — three components need the same user; three identical requests.
- Refetching — data goes stale when the user returns to the tab or reconnects.
- Retries — a transient network blip shows an error instead of retrying.
- Mutations — after saving, everything showing that data needs refreshing.
- Cleanup — abort requests when the component unmounts.
- Pagination and infinite scroll state.
Doing all that by hand looks like this — and still doesn't cache:
const [user, setUser] = useState<User | null>(null)
const [error, setError] = useState<Error | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const controller = new AbortController()
setLoading(true)
fetch(`/api/users/${id}`, { signal: controller.signal })
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() })
.then(setUser)
.catch(e => { if (e.name !== 'AbortError') setError(e) })
.finally(() => setLoading(false))
return () => controller.abort()
}, [id])
The TanStack Query version
import { useQuery } from '@tanstack/react-query'
const { data: user, isPending, error } = useQuery({
queryKey: ['user', id],
queryFn: async () => {
const r = await fetch(`/api/users/${id}`)
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.json() as Promise<User>
},
})
That one hook gives you loading and error states, a cache keyed by ['user', id], deduplication, race-condition safety, retries, and refetch on window focus. Set it up once at the root:
const queryClient = new QueryClient()
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
Query keys are the cache
The queryKey identifies the data. Same key anywhere in the app → same cached data, one request. Include every variable the query depends on (['projects', { status, page }]), just like a dependency array.
staleTime controls how long data counts as fresh. The default (0) means "refetch in the background whenever it's used again" — safe but chatty. For data that rarely changes, set staleTime: 60_000 or more.
Mutations and invalidation
Saving data is where the library pays for itself:
const queryClient = useQueryClient()
const rename = useMutation({
mutationFn: (name: string) =>
fetch(`/api/projects/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] })
},
})
<button disabled={rename.isPending} onClick={() => rename.mutate('New name')}>Save</button>
After success, every query starting with ['projects'] refetches — the list, the detail page, the sidebar count. No manual syncing. You can also do optimistic updates: change the cache immediately and roll back on error.
Comparison
useEffect + fetch |
TanStack Query | |
|---|---|---|
| Loading / error state | Manual | Built in |
| Caching across components and navigation | No | Yes |
| Race conditions | Manual guard | Handled |
| Retries, refetch on focus | Manual | Built in |
| Mutations + refresh related data | Manual | invalidateQueries |
| Devtools | No | Yes |
| Extra dependency | None | ~one library |
SWR is a lighter alternative with similar ideas. If you use Redux Toolkit, RTK Query fills the same role.
When you don't need it
- Server Components (Next.js App Router): fetch on the server with
await, no client library needed for initial data. (React Server Components explained) - Framework loaders (React Router, Remix, SvelteKit) already handle loading and caching per route.
- One-off fetches in a tiny app, or data that loads once and never changes.
Many apps combine them: server components for initial page data, TanStack Query for client-side interactivity, polling and mutations.
Don't put server data in global state
A common anti-pattern (especially in AI-generated apps) is fetching data and copying it into Redux/Zustand/context. Now there are two sources of truth to keep in sync. Server data belongs in a server-state cache (TanStack Query); global stores are for client state like UI preferences. (React state management)
The summary
useEffectfetching lacks caching, dedupe, retries, race-condition safety and mutation syncing.- TanStack Query provides all of that with
useQueryanduseMutation. - Query keys identify cached data;
invalidateQueriesrefreshes after mutations. - Server Components or framework loaders can replace it for initial page data.
EasySpawn runs your React frontend and API together on one server, with Postgres alongside — fewer network hops and no cold starts between your queries and your data. See how it works or join the waitlist.
Related: useEffect Explained · React State Management · Failed to Fetch · API Pagination
Keep reading
React State Management: useState vs Context vs Zustand vs Redux
Most React apps need less state management than they think. Sort your state into server, URL, form, local and global, then pick the lightest tool for each: useState, the URL, React Context, Zustand, Redux Toolkit or Jotai. Trade-offs, examples and common mistakes.
React Server Components Explained: "use client", "use server", and What Runs Where
Server Components render on the server and send no JavaScript; Client Components hydrate in the browser for interactivity. How the boundary works, what 'use client' and 'use server' actually mean, passing data across, common errors, and how to keep secrets and bundles where they belong.