TanStack Query (formerly React Query) is a library for managing
server state in React apps. It solves the boilerplate problem of
using useState + useEffect to fetch data: no more manual loading
flags, error states, cache invalidation, or race-condition handling.
// Without React Query — manual state machine
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
setLoading(true)
fetch('/api/user')
.then(r => r.json())
.then(d => { setData(d); setLoading(false) })
.catch(e => { setError(e); setLoading(false) })
}, [])
// With React Query — three lines replace the whole block
const { data, isLoading, error } = useQuery({
queryKey: ['user'],
queryFn: () => fetch('/api/user').then(r => r.json()),
})
React Query also handles caching, background refetching, deduplication of concurrent requests, and retry logic — all out of the box.
Rule of thumb: if the data lives on a server and you want it fresh in the UI, React Query is the right tool; it is not for ephemeral UI state like modal toggles.
useQuery needs at minimum a queryKey and a queryFn. The third
common option is staleTime.
import { useQuery } from '@tanstack/react-query'
function UserProfile({ userId }) {
const { data, isLoading, isError, error } = useQuery({
queryKey: ['user', userId], // cache key — array form
queryFn: () =>
fetch(`/api/users/${userId}`).then(r => r.json()), // async fetcher
staleTime: 1000 * 60 * 5, // treat data as fresh for 5 min
})
if (isLoading) return <Spinner />
if (isError) return <p>{error.message}</p>
return <h1>{data.name}</h1>
}
The hook returns many flags — isPending, isFetching, isSuccess,
isError, isStale — plus data, error, and refetch. isLoading
is true only on the very first fetch (no cached data + fetching); use
isFetching to show a background-refetch spinner.
Rule of thumb: always destructure only what you need; the full return object has ~25 properties.
Query keys are the primary key for the query cache. Every unique key maps to its own cache entry. React Query uses them to deduplicate in-flight requests, invalidate targeted entries after mutations, and re-run queries when the key changes.
// Different keys → different cache entries
useQuery({ queryKey: ['user', 1], queryFn: ... }) // cache: user/1
useQuery({ queryKey: ['user', 2], queryFn: ... }) // cache: user/2
// Key changes when userId changes → automatic re-fetch
useQuery({ queryKey: ['user', userId], queryFn: fetchUser })
// Invalidate all 'user' queries after an update
queryClient.invalidateQueries({ queryKey: ['user'] })
Keys are serialized deeply (arrays and objects both work), so
['users', { status: 'active' }] and ['users', { status: 'inactive' }]
are independent cache slots. Treat keys like a URL: they should
uniquely and fully describe the data being fetched.
Rule of thumb: always include every variable the query depends on (IDs, filters, page numbers) in the key — never leave a dependency out.
staleTime controls how long fetched data is considered fresh.
During that window React Query will serve cached data without
re-fetching. Default is 0 (immediately stale).
gcTime (formerly cacheTime) controls how long unused cached
data is kept in memory before garbage collection. Default is 5 minutes.
useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
staleTime: 1000 * 60, // data is fresh for 1 min
gcTime: 1000 * 60 * 10, // cache kept 10 min after last observer
})
// 0–60 s: component mounts → serves cache, no network request
// 60+ s: data is stale → re-fetch in background on next mount/focus
// unmount: cache kept 10 min then collected
A high staleTime with a long gcTime means near-instant navigation
between pages without spinners, while the data stays reasonably fresh.
Rule of thumb: staleTime = "how often can I tolerate stale data";
gcTime = "how long should I cache data after nobody is watching it."
React Query triggers a background refetch — silently updating stale data without showing a spinner — in four situations by default:
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
// all true by default:
refetchOnWindowFocus: true, // tab gets focus again
refetchOnMount: true, // new component instance mounts
refetchOnReconnect: true, // network comes back online
staleTime: 0, // data is immediately stale
})
During a background refetch isFetching is true but isLoading
remains false (cached data still displayed). This lets you show a
subtle "refreshing" indicator without blanking the screen.
You can also trigger refetches manually via refetch() or via
queryClient.invalidateQueries({ queryKey: ['todos'] }).
Rule of thumb: increase staleTime to reduce unnecessary network
traffic; set refetchOnWindowFocus: false in forms or dashboards where
a mid-edit refresh would confuse users.
useQuery returns boolean flags for every state in the request lifecycle.
Use them to render the right UI slice.
const { data, isLoading, isError, error, isSuccess } = useQuery({
queryKey: ['profile'],
queryFn: fetchProfile,
})
// isLoading: true only on first fetch (no cached data)
if (isLoading) return <Skeleton />
// isError: true when queryFn threw or rejected
if (isError) return <Alert message={error.message} />
// isSuccess: true once data is available (even stale)
return <Profile user={data} />
For background refreshes where you want to keep showing old data while
new data loads, check isFetching alongside isSuccess.
Rule of thumb: check isLoading for the empty-state skeleton and
isError for the error boundary — never check !data as a loading
proxy, because stale data can exist alongside a fresh fetch.
useMutation handles write operations (POST, PUT, DELETE). Unlike
useQuery it does not run automatically — you call mutate() or
mutateAsync() explicitly.
const mutation = useMutation({
mutationFn: (newTodo) =>
fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
}).then(r => r.json()),
onSuccess: (data, variables, context) => {
// data = server response, variables = what you passed to mutate()
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
onError: (error, variables, context) => {
console.error('Mutation failed:', error.message)
},
onSettled: () => {
// runs after onSuccess OR onError — good for cleanup
},
})
// trigger it
mutation.mutate({ title: 'Buy milk' })
mutation.status cycles through idle → pending → success | error.
Use mutation.isPending to disable the submit button.
Rule of thumb: always invalidate or update the relevant query cache
in onSuccess so the UI stays in sync with the server.
Optimistic updates apply the expected change to the cache immediately
— before the server responds — so the UI feels instant. If the mutation
fails you roll back using the snapshot saved in onMutate.
const queryClient = useQueryClient()
const toggleTodo = useMutation({
mutationFn: (todo) =>
fetch(`/api/todos/${todo.id}`, { method: 'PATCH',
body: JSON.stringify({ done: !todo.done }) }).then(r => r.json()),
onMutate: async (todo) => {
// 1. cancel any in-flight refetch (avoids overwriting our optimistic data)
await queryClient.cancelQueries({ queryKey: ['todos'] })
// 2. snapshot the previous value
const previous = queryClient.getQueryData(['todos'])
// 3. optimistically update the cache
queryClient.setQueryData(['todos'], (old) =>
old.map(t => t.id === todo.id ? { ...t, done: !t.done } : t)
)
return { previous } // returned as "context"
},
onError: (_err, _todo, context) => {
// 4. roll back on failure
queryClient.setQueryData(['todos'], context.previous)
},
onSettled: () => {
// 5. always refetch to sync with server truth
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
The pattern — cancel → snapshot → optimistic write → rollback on error → invalidate on settle — is the canonical React Query optimistic workflow.
Rule of thumb: use optimistic updates for high-frequency interactions (toggles, likes, reorder) where a 200–500 ms spinner would feel sluggish.
Query invalidation marks a cached query as stale so that React Query refetches it the next time an active observer is present (or immediately if one is currently mounted).
// After creating a new post, invalidate the list
const mutation = useMutation({
mutationFn: createPost,
onSuccess: () => {
// exact: false (default) — invalidates 'posts' and all sub-keys
queryClient.invalidateQueries({ queryKey: ['posts'] })
// exact: true — invalidates only this exact key
queryClient.invalidateQueries({ queryKey: ['posts', 'list'], exact: true })
},
})
Invalidation is preferred over setQueryData for list-after-create
because the server may add timestamps, IDs, or derived fields that the
client can't predict. Re-fetching gets you the canonical truth.
Rule of thumb: invalidate for create/delete operations (server adds
data you don't have); use setQueryData only for update operations where
you already have the complete new object.
Use the enabled option. When enabled is false the query is
suspended — it will not fetch until the expression becomes truthy.
function UserOrders({ userId }) {
// Step 1: fetch the user
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
// Step 2: fetch orders only once we have user.accountId
const { data: orders } = useQuery({
queryKey: ['orders', user?.accountId],
queryFn: () => fetchOrders(user.accountId),
enabled: !!user?.accountId, // waits for accountId to exist
})
return <OrderList orders={orders} />
}
enabled accepts any expression — !!id, isSuccess, a feature flag
— and React Query will start the query the moment the value becomes
truthy.
Rule of thumb: always guard with !!value not just value, so that
empty strings and 0 don't accidentally enable a query.
useInfiniteQuery manages a list of pages. Each page is fetched via
queryFn which receives pageParam (the cursor/page number for that
page). getNextPageParam derives the next cursor from the last page.
const {
data, // { pages: [...], pageParams: [...] }
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 0 }) =>
fetch(`/api/posts?cursor=${pageParam}`).then(r => r.json()),
getNextPageParam: (lastPage) =>
lastPage.nextCursor ?? undefined, // undefined stops pagination
initialPageParam: 0, // v5 required option
})
// Flatten all pages for rendering
const posts = data?.pages.flatMap(p => p.items) ?? []
Call fetchNextPage() from an "Load more" button or an intersection
observer at the bottom of the list.
Rule of thumb: useInfiniteQuery is for cursor-based or
offset-based lists; for discrete page navigation (page 1 / 2 / 3
buttons) use a plain useQuery with the page number in the key.
Create a QueryClient instance once at app startup and wrap the
tree with QueryClientProvider. Every hook inside the tree shares
that client.
// main.tsx / _app.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // global 1-min freshness
retry: 2, // retry failed queries twice
},
},
})
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<Router />
<ReactQueryDevtools initialIsOpen={false} /> {/* dev-only panel */}
</QueryClientProvider>
)
}
QueryClient is created outside the component so it is not re-created
on every render. In tests, create a fresh QueryClient per test to
prevent shared state between cases.
Rule of thumb: never create the QueryClient inside a component —
it would reset the cache on every render.
prefetchQuery populates the cache proactively — before the
component that needs the data has mounted. This eliminates the loading
state for routes the user is likely to visit next.
const queryClient = useQueryClient()
// On mouse-enter of a "User Profile" link
function UserLink({ userId }) {
const handleMouseEnter = () => {
queryClient.prefetchQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 1000 * 60 * 5, // don't prefetch if cached and fresh
})
}
return <Link to={`/users/${userId}`} onMouseEnter={handleMouseEnter}>...</Link>
}
You can also call prefetchQuery in a route loader (React Router v6+,
Next.js getServerSideProps, TanStack Router) to fetch data on the
server before the page renders.
Rule of thumb: prefetch on hover/intent; do not prefetch every possible route eagerly or you'll waste bandwidth.
Server state is data that lives on a remote server and is fetched asynchronously — it can change outside your app (another user edits it, a cron job updates it). Client state is ephemeral UI state that only your app owns: modal open/closed, form draft, selected tab.
// Client state — lives only in the browser
const [isModalOpen, setIsModalOpen] = useState(false)
// Server state — a snapshot of remote data
const { data: todos } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
// "todos" can become stale while the user is on the page
Server state has extra concerns client state doesn't: caching, staleness,
background synchronisation, deduplication, pagination. Managing it with
useState forces you to re-implement all of these manually.
Rule of thumb: use useState/useReducer/Zustand for client state;
use React Query (or SWR) for server state — keep the two layers separate.
Include the page number in the query key so each page has its own
cache entry. Use placeholderData: keepPreviousData (v5) to avoid
blanking the UI between pages.
import { useQuery, keepPreviousData } from '@tanstack/react-query'
function TodoList() {
const [page, setPage] = useState(1)
const { data, isPlaceholderData } = useQuery({
queryKey: ['todos', page],
queryFn: () => fetchTodos(page),
placeholderData: keepPreviousData, // keep showing page N while N+1 loads
})
return (
<>
{isPlaceholderData && <LoadingBar />}
<List items={data?.items} />
<button
onClick={() => setPage(p => p - 1)}
disabled={page === 1}>Prev</button>
<button
onClick={() => setPage(p => p + 1)}
disabled={!data?.hasMore}>Next</button>
</>
)
}
React Query can also prefetch the next page when hasMore is true,
making forward navigation feel instant.
Rule of thumb: always put pagination state in the query key; never mutate an external variable that the key doesn't reflect.
Use React Query when data originates from a server and your main concerns are caching, staleness, and background sync. Use Redux when you have complex cross-cutting client state (undo/redo, multi-step workflows, real-time collaborative state shared between many components).
// React Query — server data, auto-caching, zero boilerplate
const { data: user } = useQuery({ queryKey: ['user'], queryFn: getUser })
// Redux (RTK Query) — same fetching capability, but adds global client state
const user = useSelector(selectUser) // cross-slice derivations
dispatch(userSlice.actions.setRole('admin')) // synchronous client mutation
Many teams use both: React Query for server state, Zustand or Redux for the small amount of true client state (auth session, UI preferences). RTK Query (Redux Toolkit's built-in fetching solution) is an alternative if you're already invested in Redux.
Rule of thumb: reach for React Query first; only add Redux if you genuinely need global synchronous state across many parts of the app.
Both SWR (Vercel) and React Query implement stale-while-revalidate caching. The differences are in scope and power:
// SWR — minimal, opinionated API
const { data, error, isLoading } = useSWR('/api/user', fetcher)
// React Query — richer API: mutations, infinite queries, optimistic updates
const { data, isLoading } = useQuery({ queryKey: ['/api/user'], queryFn: fetcher })
const mutation = useMutation({ mutationFn: updateUser })
SWR is simpler — almost no configuration, tiny bundle. React Query has
more features: useMutation with full lifecycle callbacks, useInfiniteQuery,
QueryClient for server-side prefetching, advanced select transforms,
and the excellent DevTools panel.
Rule of thumb: SWR for small apps that only need fetch-and-cache; React Query when you also need mutations, optimistic updates, pagination, or fine-grained cache control.
Pass useSuspenseQuery (v5) instead of useQuery to let React
Suspense handle the loading state declaratively. The component suspends
while data loads; wrap it in <Suspense> with a fallback.
// v5 API
import { useSuspenseQuery } from '@tanstack/react-query'
function UserProfile({ userId }) {
// throws a Promise while loading → Suspense catches it
const { data } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
// data is ALWAYS defined here — no isLoading check needed
return <h1>{data.name}</h1>
}
// Parent
function Page() {
return (
<ErrorBoundary fallback={<ErrorPage />}>
<Suspense fallback={<Skeleton />}>
<UserProfile userId={1} />
</Suspense>
</ErrorBoundary>
)
}
Error states bubble to the nearest <ErrorBoundary> instead of being
returned as a flag. This moves loading and error handling out of the
data component and into the layout layer.
Rule of thumb: use Suspense mode when you want co-located data
fetching with parent-controlled loading UI; keep the imperative
isLoading approach in forms and components that handle their own
error display.
Use HydrationBoundary with dehydrate to pass the server-fetched
cache to the client, avoiding a client-side refetch on first render.
// app/users/page.tsx (Next.js App Router)
import { dehydrate, HydrationBoundary, QueryClient }
from '@tanstack/react-query'
export default async function UsersPage() {
const queryClient = new QueryClient()
// prefetch on the server
await queryClient.prefetchQuery({
queryKey: ['users'],
queryFn: fetchUsers,
})
return (
// serialise the cache and send it to the client
<HydrationBoundary state={dehydrate(queryClient)}>
<UserList /> {/* useQuery(['users']) hits the cache immediately */}
</HydrationBoundary>
)
}
On the client, React Query reads the dehydrated state and populates its cache before components mount, so they render with data immediately and no hydration mismatch occurs.
Rule of thumb: always create a fresh QueryClient per request on the
server — never share the same instance across requests or you'll leak data
between users.
Set refetchInterval to a millisecond value. React Query will refetch
the query on that interval while the component is mounted. Set
refetchIntervalInBackground: true to keep polling even when the tab is
not focused.
const { data: jobStatus } = useQuery({
queryKey: ['job', jobId],
queryFn: () => fetchJobStatus(jobId),
refetchInterval: 3000, // poll every 3 seconds
refetchIntervalInBackground: false, // pause when tab is hidden (default)
})
// Dynamic interval — stop polling once the job is done
const { data } = useQuery({
queryKey: ['job', jobId],
queryFn: () => fetchJobStatus(jobId),
refetchInterval: (query) => {
// return false to stop; return ms to continue
return query.state.data?.status === 'done' ? false : 2000
},
})
The function form of refetchInterval receives the current query object,
letting you implement "poll until done" patterns without extra state.
Rule of thumb: always use the function form when you want to stop polling on a condition — a fixed interval that never stops wastes network on completed resources.
More State Management interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.