Every React developer has written it: a useEffect that fires on mount,
sets a loading flag, calls fetch, then fans out into three separate
setState calls for data, loading, and error. It works — until you need
caching, background refreshes, race-condition protection, pagination, or
retry logic. At that point the 20-line hook becomes a 100-line custom hook
that you maintain forever.
TanStack Query (React Query) exists to solve exactly this problem. It is not a general-purpose state manager. It is a dedicated layer for server state — data that lives on a server, changes asynchronously, and needs to stay synchronized with your UI. Understanding when and why to reach for it is one of the clearest signals that separates junior from senior React developers in interviews.
Server State vs Client State — The Mental Model Shift
Before writing a single line of React Query, you need to understand the fundamental distinction it is built on.
Client state is ephemeral and owned entirely by your app: whether a modal is open, which tab is selected, a partially typed form value. You are the only author. It never goes stale.
Server state is a snapshot of remote data that your app does not fully own: a list of todos, a user profile, an order history. It can change outside your app at any moment — another user edits a record, a background job updates a field. Because of this, server state has properties that client state never has:
- It can become stale while your component is on screen.
- Multiple components might request the same data simultaneously and should share one network request.
- It needs to be cached so navigation feels instant.
- It must be invalidated after mutations so the UI reflects the latest server truth.
Managing server state with useState forces you to re-implement all of
these concerns by hand. React Query gives you the full solution out of the
box.
useQuery — Fetching, Caching, and Refetching
useQuery is the workhorse. At minimum it takes a queryKey and a
queryFn:
import { useQuery } from '@tanstack/react-query'
function TodoList() {
const { data, isLoading, isError, error, isFetching } = useQuery({
queryKey: ['todos'],
queryFn: () => fetch('/api/todos').then(r => r.json()),
staleTime: 1000 * 30, // treat data as fresh for 30 seconds
})
if (isLoading) return <Skeleton />
if (isError) return <ErrorBanner message={error.message} />
return (
<>
{isFetching && <RefreshIndicator />} {/* background refresh */}
<ul>{data.map(todo => <TodoItem key={todo.id} {...todo} />)}</ul>
</>
)
}
Two flags that trip up developers:
isLoadingistrueonly when there is no cached data and a fetch is in progress. It is the "blank slate" state — show a skeleton.isFetchingistruewhenever any fetch is in progress, including background refetches. Use it to show a subtle "refreshing" indicator without blanking the already-rendered content.
Background refetching is one of React Query's most valuable behaviours. By default, every time a component mounts, the browser tab regains focus, or the network reconnects, React Query checks whether the cached data is stale and re-fetches it silently if so. Users always see up-to-date data without manual refresh buttons.
You control this via staleTime. A staleTime of 0 (the default) means
data is immediately stale after fetching — a re-focus will always trigger a
background refetch. A staleTime of Infinity means the data is treated
as permanently fresh and React Query will never refetch it automatically.
Query Keys — The Cache's Primary Key
Query keys are more important than they first appear. The cache is a key-value store where each unique key maps to its own cached response. Get the key design wrong and you will either miss re-fetches or hit the network more than necessary.
Best practice is to use arrays and include every variable the fetcher depends on:
// Bad — same key for every user, cache will collide
useQuery({ queryKey: ['user'], queryFn: () => fetchUser(userId) })
// Good — each userId gets its own cache slot
useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId) })
// Good — filters are part of the identity
useQuery({
queryKey: ['todos', { status: 'active', page: 2 }],
queryFn: () => fetchTodos({ status: 'active', page: 2 }),
})
When a variable changes (the user navigates from userId=1 to userId=2)
React Query detects the key change and fires a new fetch automatically.
This replaces the useEffect dependency array pattern entirely for data
fetching.
Keys also power targeted invalidation. Because keys form a hierarchy,
queryClient.invalidateQueries({ queryKey: ['todos'] }) marks every key
that starts with 'todos' as stale — ['todos'], ['todos', 1],
['todos', { status: 'active' }] — all in one call.
useMutation and Optimistic Updates
useMutation handles write operations. Unlike useQuery it does not run
automatically — you call mutate() from an event handler:
const queryClient = useQueryClient()
const createTodo = useMutation({
mutationFn: (text) =>
fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ text }),
}).then(r => r.json()),
onSuccess: () => {
// Invalidate the list so it refetches with the new item
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
function AddTodoForm() {
const [text, setText] = useState('')
return (
<form onSubmit={e => { e.preventDefault(); createTodo.mutate(text) }}>
<input value={text} onChange={e => setText(e.target.value)} />
<button disabled={createTodo.isPending}>
{createTodo.isPending ? 'Saving…' : 'Add'}
</button>
</form>
)
}
For high-frequency interactions — toggling a like, reordering a list — waiting for the server response before updating the UI feels sluggish. Optimistic updates fix this by applying the expected change to the cache immediately and rolling back on failure:
const toggleLike = useMutation({
mutationFn: (postId) => fetch(`/api/posts/${postId}/like`, { method: 'POST' }),
onMutate: async (postId) => {
await queryClient.cancelQueries({ queryKey: ['posts'] }) // stop in-flight refetches
const previous = queryClient.getQueryData(['posts']) // snapshot
queryClient.setQueryData(['posts'], (old) => // apply optimistically
old.map(p => p.id === postId ? { ...p, liked: !p.liked } : p)
)
return { previous }
},
onError: (_err, _postId, context) => {
queryClient.setQueryData(['posts'], context.previous) // roll back
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] }) // sync with server
},
})
The five-step pattern — cancel → snapshot → apply → rollback on error →
invalidate on settle — is the canonical approach. Every step matters;
skipping cancelQueries can cause a race where a background refetch
overwrites your optimistic update.
React Query vs Redux for Server State
This comparison comes up in almost every senior React interview. The short answer: they solve different problems.
Redux is a global synchronous state container. It is excellent for client state that is shared across many parts of the app, complex state machines, or undo/redo functionality. The canonical Redux data-fetching solution is RTK Query (built into Redux Toolkit), which is essentially Redux + a React-Query-like caching layer.
React Query is purpose-built for server state. It is not a general state manager — you cannot put a modal's open/closed flag into it. What it does better than raw Redux for fetching:
React Query Redux (RTK Query)
─────────────────────────────────────────────────────
Setup boilerplate minimal moderate
Automatic refetching yes yes (RTK Query)
Optimistic updates built-in built-in (RTK)
Devtools excellent excellent
Client state no yes
Cross-slice derived no yes (selectors)
Learning curve low moderate–high
Bundle size ~13 kB ~10 kB (RTK)
The practical guidance: start with React Query for server state. Only add Redux if you genuinely need global synchronous client state (cross-cutting state machines, collaborative features, complex undo). Many production apps successfully combine React Query for server state with Zustand or React context for client state, never needing Redux at all.
What Seniors Know That Juniors Miss
Interviewers testing React Query knowledge look for understanding beyond the basics:
staleTime is the most impactful tuning knob. The default staleTime
of 0 means every component mount triggers a background refetch. For
reference data (countries list, configuration) setting staleTime: Infinity
eliminates redundant network requests entirely.
Query keys encode dependencies. If your queryFn closes over a
variable but that variable is not in the key, React Query will never
re-fetch when the variable changes. Treat the key as the exhaustive list of
everything the fetcher depends on.
Devtools are essential, not optional. The ReactQueryDevtools panel
shows every cached entry, its status, staleness, and observer count. You
cannot debug cache invalidation issues without it.
onSuccess was removed in v5. In React Query v5, onSuccess,
onError, and onSettled callbacks were removed from useQuery (they
remain on useMutation). Side effects after a query should live in
useEffect watching data and error, or in the QueryCache global
callbacks.
Infinite queries need flat data for rendering. useInfiniteQuery stores
data as { pages: [...] } — a list of page responses. Always flatten with
data.pages.flatMap(p => p.items) before rendering; mapping over
data.pages directly will render page objects, not individual items.
The shift from "I manage my own fetch lifecycle" to "I declare what data I
need and React Query keeps it fresh" is the mental model senior engineers
have internalised. Once you think in queries, mutations, and cache
invalidation rather than loading flags and useEffect chains, you write
less code, catch more edge cases, and end up with a significantly more
maintainable codebase.