Blog
4 min read

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

  1. Loading state — what to show before data arrives.
  2. Error state — what if the request fails (or returns 500)?
  3. Race conditions — id changes quickly; a slow old response arrives last and overwrites the new one.
  4. Caching — navigate away and back, and it fetches again with a spinner.
  5. Deduplication — three components need the same user; three identical requests.
  6. Refetching — data goes stale when the user returns to the tab or reconnects.
  7. Retries — a transient network blip shows an error instead of retrying.
  8. Mutations — after saving, everything showing that data needs refreshing.
  9. Cleanup — abort requests when the component unmounts.
  10. 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

  • useEffect fetching lacks caching, dedupe, retries, race-condition safety and mutation syncing.
  • TanStack Query provides all of that with useQuery and useMutation.
  • Query keys identify cached data; invalidateQueries refreshes 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