TanStack Query v5: Stop Managing Server State by Hand

TanStack Query v5: Stop Managing Server State by Hand

I was deep into my third project when I realized I was copy-pasting the same pattern every time:

1const [data, setData] = useState(null)
2const [isLoading, setIsLoading] = useState(true)
3const [error, setError] = useState(null)
4
5useEffect(() => {
6  setIsLoading(true)
7  fetch('/api/posts')
8    .then(res => res.json())
9    .then(json => { setData(json); setIsLoading(false) })
10    .catch(err => { setError(err); setIsLoading(false) })
11}, [])
12

Eight lines for what should be one. It doesn't cache, doesn't deduplicate requests, doesn't refetch in the background when the window regains focus, and has no retry logic. TanStack Query (formerly React Query) handles all of that — and more — with a clean API.

What is TanStack Query?

TanStack Query is a library for managing server state in React. Server state is fundamentally different from UI state: it lives on a remote server, can become stale, must be fetched asynchronously, and is often needed by multiple components at once.

1useState
and
1useReducer
are the right tools for UI state — a dropdown open/closed, a form field value, a toggle. TanStack Query is the right tool for data that comes from an API.

Installation

1npm install @tanstack/react-query
2

Wrap your app (or the relevant subtree) with

1QueryClientProvider
:

1// main.jsx or App.jsx
2import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
3
4const queryClient = new QueryClient()
5
6export default function App() {
7  return (
8    <QueryClientProvider client={queryClient}>
9      <Router />
10    </QueryClientProvider>
11  )
12}
13

The

1QueryClient
holds the in-memory cache. Every component inside the provider shares it automatically.

Fetching data with useQuery

1import { useQuery } from '@tanstack/react-query'
2
3function PostList() {
4  const { data, isPending, isError, error } = useQuery({
5    queryKey: ['posts'],
6    queryFn: async () => {
7      const res = await fetch('/api/posts')
8      if (!res.ok) throw new Error('Network response was not ok')
9      return res.json()
10    },
11  })
12
13  if (isPending) return <p>Loading…</p>
14  if (isError) return <p>Error: {error.message}</p>
15
16  return (
17    <ul>
18      {data.map(post => (
19        <li key={post.id}>{post.title}</li>
20      ))}
21    </ul>
22  )
23}
24

Three things worth calling out:

1queryKey
is the cache key. Any component that calls
1useQuery
with the same key shares the same cached result — the network request fires exactly once, even if fifty components subscribe simultaneously. Include dynamic values in the key array (
1['posts', userId]
) and TanStack Query treats them as separate cache entries automatically.

1queryFn
must return a promise that resolves to your data or throws on error. The library wraps it in retry logic (three attempts by default), background refetching on window focus, and garbage collection of stale entries.

1isPending
(renamed from
1isLoading
in v5) is
1true
only when there is no cached data and the query is fetching for the first time. Navigate away and come back — stale data renders instantly while a silent background refetch happens. Your users never see a loading spinner for data they already loaded minutes ago.

Mutating data with useMutation

Reading is half the picture. Here is how to create a post and automatically refresh the list without touching any state:

1import { useMutation, useQueryClient } from '@tanstack/react-query'
2
3function CreatePost() {
4  const queryClient = useQueryClient()
5
6  const { mutate, isPending, isError, error } = useMutation({
7    mutationFn: async (newPost) => {
8      const res = await fetch('/api/posts', {
9        method: 'POST',
10        headers: { 'Content-Type': 'application/json' },
11        body: JSON.stringify(newPost),
12      })
13      if (!res.ok) throw new Error('Failed to create post')
14      return res.json()
15    },
16    onSuccess: () => {
17      // Mark the posts list as stale and trigger a refetch
18      queryClient.invalidateQueries({ queryKey: ['posts'] })
19    },
20  })
21
22  return (
23    <div>
24      <button
25        onClick={() => mutate({ title: 'My new post', body: 'Content here…' })}
26        disabled={isPending}
27      >
28        {isPending ? 'Saving…' : 'Create post'}
29      </button>
30      {isError && <p className="error">Error: {error.message}</p>}
31    </div>
32  )
33}
34

1invalidateQueries
marks every cached query matching that key as stale and fires a refetch. The list component re-renders with fresh data the moment it arrives — no
1setData
, no
1useState
, no synchronization logic.

Useful configuration options

1const { data } = useQuery({
2  queryKey: ['posts', userId],
3  queryFn: () => fetchPostsByUser(userId),
4  staleTime: 5 * 60 * 1000,  // treat data as fresh for 5 minutes
5  gcTime: 10 * 60 * 1000,    // keep unused cache entries for 10 minutes
6  enabled: !!userId,           // skip the query entirely if userId is falsy
7  retry: 2,                    // retry on error (default is 3)
8})
9

1staleTime
is the one I adjust most often. The default is
10
, meaning data is immediately stale and a background refetch fires on every mount. For data that changes infrequently — a user profile, a list of categories — bumping this to a few minutes eliminates unnecessary network traffic.

1enabled
is essential when a query depends on a value that isn't available yet: a user ID from an auth query, a URL param, or a selected item in the UI. Without
1enabled
, the query fires with
1undefined
and your
1queryFn
has to defensively handle that.

Note that

1gcTime
(garbage-collection time) was called
1cacheTime
in v4 — if you're migrating from an older project, rename it.

Add the DevTools

During development, install the devtools package and drop it inside your provider:

1npm install @tanstack/react-query-devtools
2
1import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
2
3<QueryClientProvider client={queryClient}>
4  <Router />
5  <ReactQueryDevtools initialIsOpen={false} />
6</QueryClientProvider>
7

You get a floating panel that shows every active query, its status, cache age, and the raw data. It changes how you debug data-fetching issues entirely — far better than

1console.log
sprinkled around
1useEffect
bodies.

When to reach for it

TanStack Query is the right call when:

  • You're fetching data from a REST or GraphQL API inside a React component
  • Multiple components need the same remote data
  • You want caching, retries, and background refetching without writing the plumbing yourself

It's overkill for fully static data (constants, feature flags bundled at build time) or purely local UI state. For those,

1useState
or Context is still the right tool.

If you're on Next.js App Router, React Server Components handle the initial fetch on the server, but TanStack Query still earns its place for client-side interactions: mutations, optimistic updates, polling, and any query that reacts to user input in real time.


If you're still manually juggling loading/error/data triples in

1useState
, give TanStack Query one afternoon. The boilerplate evaporates, the cache just works, and the refetch behavior is exactly what users expect — without you having to think about it.