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.
and1useState
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.1useReducer
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
holds the in-memory cache. Every component inside the provider shares it automatically.1QueryClient
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:
is the cache key. Any component that calls 1queryKey
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 (1useQuery
) and TanStack Query treats them as separate cache entries automatically.1['posts', userId]
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.1queryFn
(renamed from 1isPending
in v5) is1isLoading
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.1true
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
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 — no1invalidateQueries
, no1setData
, no synchronization logic.1useState
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
is the one I adjust most often. The default is1staleTime
, 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.10
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. Without1enabled
, the query fires with1enabled
and your1undefined
has to defensively handle that.1queryFn
Note that
(garbage-collection time) was called1gcTime
in v4 — if you're migrating from an older project, rename it.1cacheTime
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
sprinkled around1console.log
bodies.1useEffect
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,
or Context is still the right tool.1useState
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
, 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.1useState
