React Query (TanStack Query): Server State Caching
React Query solves the hard parts of server state: caching, synchronization, background refresh, request deduplication, and mutation consistency. The interview-level understanding is knowing when to invalidate vs when to update cache directly, and how staleTime/gcTime change network behavior and UX.
Quick Decision Guide
### Quick Setup
1) Add Provider
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
export function AppProviders({ children }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}2) Fetch data (useQuery)
import { useQuery } from '@tanstack/react-query'
function User({ userId }) {
const q = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 60_000
})
if (q.isLoading) return 'Loading...'
if (q.error) return 'Error'
return q.data.name
}3) Update data (useMutation)
import { useMutation, useQueryClient } from '@tanstack/react-query'
function EditUser({ userId }) {
const qc = useQueryClient()
const m = useMutation({
mutationFn: updateUser,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['user', userId] })
}
})
return <button onClick={() => m.mutate({ id: userId, name: 'John' })}>Save</button>
}Big idea: React Query owns server state. Use keys, staleTime, invalidation, and cache updates to keep UI fast + correct.
Query Keys (Cache Identity)
QueryKey Structure
Query keys are the identity of cached data. Prefer arrays:
['users']
['user', userId]
['posts', { status: 'published', page: 1 }]Rules
Query function
async function fetchUser(id) {
const res = await fetch(`/api/users/${id}`)
if (!res.ok) throw new Error('Failed')
return res.json()
}Caching Strategy (staleTime, gcTime, refetch triggers)
staleTime
How long data is considered fresh. Fresh queries don't refetch on mount/focus.
useQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
staleTime: 5 * 60 * 1000
})gcTime (garbage collection)
How long inactive queries remain in memory before removal.
Refetch Triggers (defaults can vary by config)
Practical defaults
Mutations: Correctness vs Instant UX
invalidateQueries (correctness-first)
After a mutation, refetch related queries.
const qc = useQueryClient()
useMutation({
mutationFn: updateUser,
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: ['user', vars.id] })
qc.invalidateQueries({ queryKey: ['users'] })
}
})setQueryData (instant UI)
Update cache immediately when you know the new state.
onSuccess: (updated) => {
qc.setQueryData(['user', updated.id], updated)
}Rule of thumb
Optimistic Updates (with rollback)
Optimistic Update Pattern
Update cache first, rollback on error.
const qc = useQueryClient()
useMutation({
mutationFn: updateUser,
onMutate: async (vars) => {
await qc.cancelQueries({ queryKey: ['user', vars.id] })
const prev = qc.getQueryData(['user', vars.id])
qc.setQueryData(['user', vars.id], (old) => ({
...old,
name: vars.name
}))
return { prev }
},
onError: (_err, vars, ctx) => {
qc.setQueryData(['user', vars.id], ctx?.prev)
},
onSettled: (_data, _err, vars) => {
qc.invalidateQueries({ queryKey: ['user', vars.id] })
}
})Pitfalls
Pagination & Infinite Scroll
Pagination
Keep page params in the query key.
useQuery({
queryKey: ['posts', { page, filter }],
queryFn: () => fetchPosts({ page, filter }),
keepPreviousData: true
})Infinite queries
import { useInfiniteQuery } from '@tanstack/react-query'
useInfiniteQuery({
queryKey: ['posts', { filter }],
queryFn: ({ pageParam = 0 }) => fetchPosts({ cursor: pageParam, filter }),
getNextPageParam: (lastPage) => lastPage.nextCursor
})Next.js App Router Notes
Next.js (App Router)
Rule: