Most React apps I inherit have a global state library (Redux, Zustand, Context) doing two jobs at once: managing UI state and storing a mirror of the server response. The second job is where the suffering comes from.
You fetch a list of users, store it in Redux, then write reducers to add, update, and remove users from the mirror. Every new endpoint needs a new reducer. Every mutation needs to update the mirror in three places. The mirror drifts from the server. The cache strategy is "refetch everything on every mount" because you do not trust the local copy.
TanStack Query does that job properly. It treats server state as a cache that gets invalidated on demand, not as a mirror you maintain by hand.
Server state vs client state
Before reaching for any library, sort your state into two piles:
- Server state — anything that exists on your API. Lists of items, item details, user profiles, settings fetched from the backend.
- Client state — anything that exists only in the browser. Which modal is open, the currently selected tab, the contents of a draft form.
Most codebases put both in the same store. Splitting them changes everything.
- Server state → TanStack Query
- Client state (low frequency) → Context or Zustand
- Client state (high frequency) → Zustand with selectors
Once you make this split, half of your Redux store disappears.
The basic query
import { useQuery } from '@tanstack/react-query'
function UserList() {
const { data, isPending, isError } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
})
if (isPending) return <Spinner />
if (isError) return <ErrorState />
return data.map(user => <UserRow key={user.id} user={user} />)
}What you get for free:
- Caching across components mounted in different places
- Background refetch on window focus
- Refetch on network reconnect
- Race condition handling for concurrent fetches
- Request deduplication — five components using
['users']make one request
Writing this by hand with useEffect and useState is a hundred lines of subtle bug-prone code. TanStack Query gives it to you in one hook.
Query keys are your cache hierarchy
The query key is a cache address. Design it like a hierarchy from broad to specific:
['users'] // all users
['users', userId] // one user
['users', userId, 'posts'] // one user's posts
['users', userId, 'posts', { sort }] // one user's posts with sortWhy this matters: invalidation. After mutating a user, you can invalidate just that user's data without touching others:
queryClient.invalidateQueries({
queryKey: ['users', userId],
})Or invalidate everything about users:
queryClient.invalidateQueries({
queryKey: ['users'],
})Without a clear hierarchy, you end up with keys like ['userDetail', 'list', 'feed'] and you cannot invalidate cleanly.
Mutations with cache invalidation
import { useMutation, useQueryClient } from '@tanstack/react-query'
function UpdateNameForm({ userId }) {
const qc = useQueryClient()
const mutation = useMutation({
mutationFn: (newName: string) => api.updateUser(userId, { name: newName }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['users', userId] })
},
})
return (
<form onSubmit={e => {
e.preventDefault()
mutation.mutate(new FormData(e.currentTarget).get('name') as string)
}}>
<input name="name" />
<button disabled={mutation.isPending}>Save</button>
</form>
)
}The mutation runs, the cache invalidates, the user detail query refetches. Any component mounted with ['users', userId] gets the fresh data automatically.
Optimistic updates for instant feedback
For mutations that should feel instant — toggles, likes, checkboxes — update the cache before the server responds:
const mutation = useMutation({
mutationFn: api.toggleLike,
onMutate: async ({ postId, newLiked }) => {
await qc.cancelQueries({ queryKey: ['posts', postId] })
const previous = qc.getQueryData(['posts', postId])
qc.setQueryData(['posts', postId], old => ({
...old,
liked: newLiked,
likeCount: old.likeCount + (newLiked ? 1 : -1),
}))
return { previous }
},
onError: (_err, _vars, context) => {
qc.setQueryData(['posts', postId], context.previous)
},
onSettled: () => {
qc.invalidateQueries({ queryKey: ['posts', postId] })
},
})The like count updates the instant the user taps. The mutation runs in the background. If it fails, the cache rolls back to the previous value. If it succeeds, the invalidation refreshes with authoritative data.
staleTime and gcTime
Two settings most developers get wrong:
-
staleTime— how long data is considered fresh. Default is0, which means every mount triggers a refetch. For data that changes rarely, set this higher. -
gcTime(formerlycacheTime) — how long unused data stays in memory before garbage collection. Default is 5 minutes.
useQuery({
queryKey: ['featureFlags'],
queryFn: fetchFlags,
staleTime: 10 * 60 * 1000, // 10 minutes
gcTime: 30 * 60 * 1000, // 30 minutes
})For feature flags, user preferences, or anything that changes rarely, bump staleTime so you are not hammering your API.
Patterns that pay off
- Pagination: use
useInfiniteQueryfor infinite scroll. It tracks pages, fetches more on demand, and caches each page. - Dependent queries: use the
enabledflag to gate queries that depend on others:enabled: !!userId. - Prefetching: prefetch the next page on hover for instant navigation.
- SSR with Next.js: use
HydrationBoundaryto serialize the server cache to the client.
When NOT to use TanStack Query
- Real-time data via WebSockets. The cache model assumes request-response. For streaming data, use the WebSocket directly and update the cache manually.
- Local-only state. UI flags, form drafts, transient state — use
useStateor Zustand. - Very low-frequency requests where a single
useEffectis fine.
TanStack Query is not another state library to add to the pile. It is the library that lets you delete the state library you were using wrong.
Want a cleaner React state architecture?
I refactor React apps to split server and client state properly, with TanStack Query and Zustand. Let's talk.
Frequently Asked Questions
What is TanStack Query used for?
TanStack Query handles server state in React apps: fetching, caching, synchronizing, and updating data from your API. It replaces the boilerplate of useEffect plus useState for data fetching and removes the need for global state libraries for most server data.
Should I use TanStack Query or Redux?
Use TanStack Query for server state and a smaller library like Zustand or Jotai for UI state. Redux is rarely needed for new apps unless you specifically want its time-travel debugging or middleware ecosystem.
How do I invalidate a TanStack Query cache?
Call useQueryClient().invalidateQueries with the query key you want to refresh. By default this refetches all active queries matching that key. Be specific — over-invalidation defeats the cache.
What is the difference between staleTime and gcTime?
staleTime controls how long data is considered fresh before a refetch is triggered on mount or refocus. gcTime (formerly cacheTime) controls how long unused data stays in memory before garbage collection. staleTime is about freshness, gcTime is about memory cleanup.