Guide
TanStack Query fundamentals explained
TanStack Query (formerly React Query) is a client-side library for
fetching, caching, synchronizing, and updating server state in web applications.
Where
React
useState holds UI state that lives only in the browser, TanStack Query
owns data that originates on a server — product catalogs, user profiles, order
histories — and treats that data as a cache with explicit freshness rules.
You declare what data a component needs via a stable query key,
and the library handles deduplication, background refetch, loading and error states,
and cache invalidation after writes. Teams adopt it to delete hundreds of lines of
hand-rolled useEffect fetch logic and to make list/detail views stay
consistent without manual prop drilling. This guide covers QueryClient setup,
useQuery and useMutation, stale time and garbage collection,
optimistic updates, infinite pagination, SSR hydration with
Next.js,
a Harbor Commerce catalog worked example, a library decision table, common pitfalls,
and a production checklist.
Server state vs client state
Not all application state belongs in Redux or useState. A useful split:
- Client state — modal open/closed, form draft text, selected tab, theme preference. Lives entirely in the browser; no network round-trip to “load” it.
- Server state — records stored in PostgreSQL, S3 objects, or a third-party API. Another user or background job can change it while your tab is open; your copy is always potentially stale.
TanStack Query is purpose-built for server state. It is not a replacement for global UI stores like Zustand or Redux Toolkit — though many apps use both. See frontend state management for when to colocate state in components vs lift it globally. The mental model: queries read remote data into a normalized cache; mutations write and then invalidate or update affected cache entries.
QueryClient and provider setup
Install @tanstack/react-query (or @tanstack/vue-query for Vue).
Create a QueryClient once per app and wrap your tree with
QueryClientProvider:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000, // 1 min before background refetch
gcTime: 5 * 60_000, // 5 min unused cache retention
retry: 1,
refetchOnWindowFocus: true,
},
},
})
function App() {
return (
<QueryClientProvider client={queryClient}>
<Routes />
</QueryClientProvider>
)
}
Default options propagate to every query unless overridden per hook. In
TypeScript
projects, type your API responses at the queryFn return site or with
generics on useQuery<Product[]>. Enable React Query Devtools in
development (@tanstack/react-query-devtools) to inspect cache entries,
staleness, and fetch status in real time.
useQuery — reading data
A query needs a unique query key (serializable array) and a query function that returns a Promise:
function ProductList() {
const { data, isPending, isError, error, refetch } = useQuery({
queryKey: ['products', { category: 'hardware' }],
queryFn: () =>
fetch('/api/products?category=hardware').then((r) => {
if (!r.ok) throw new Error('Failed to load products')
return r.json()
}),
})
if (isPending) return <p>Loading…</p>
if (isError) return <p>Error: {error.message}</p>
return <ul>{data.map((p) => <li key={p.id}>{p.name}</li>)}</ul>
}
Key behaviors that replace manual useEffect patterns:
- Deduplication — ten components mounting the same query key share one in-flight request.
- Stale-while-revalidate — cached data renders immediately; if stale, a background refetch runs without blanking the UI.
- Status flags —
isPending(no data yet),isFetching(any fetch including background),isError,isSuccess. - enabled — set
enabled: !!userIdto defer fetching until a dependency exists (e.g. wait for auth).
staleTime controls how long data is considered fresh (no automatic
refetch). gcTime (formerly cacheTime) controls how long
inactive cache entries stay in memory after the last subscriber unmounts.
Query keys and hierarchical invalidation
Query keys should be arrays that encode every variable affecting the result:
['products'] // all products
['products', { category: 'hardware' }] // filtered list
['products', productId] // single detail
['products', productId, 'reviews'] // nested resource
Hierarchical keys enable partial invalidation. After creating a product, call:
queryClient.invalidateQueries({ queryKey: ['products'] })
That marks all queries whose keys start with ['products'] as
stale — list views and detail pages refetch on next mount or focus. For surgical
updates without a network round-trip, use queryClient.setQueryData to
patch the cache when you already know the new shape (e.g. toggle a boolean flag).
useMutation — writing data
Mutations wrap POST/PUT/PATCH/DELETE calls and coordinate cache side effects:
const mutation = useMutation({
mutationFn: (newProduct) =>
fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newProduct),
}).then((r) => r.json()),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] })
},
})
// In JSX: mutation.mutate({ name: 'Bolt', sku: 'HW-001' })
// Status: mutation.isPending, mutation.isError, mutation.isSuccess
Optimistic updates improve perceived speed: update the cache before the server responds, roll back on error:
onMutate: async (newItem) => {
await queryClient.cancelQueries({ queryKey: ['products'] })
const previous = queryClient.getQueryData(['products'])
queryClient.setQueryData(['products'], (old) => [...old, { ...newItem, id: 'temp' }])
return { previous }
},
onError: (_err, _vars, context) => {
queryClient.setQueryData(['products'], context.previous)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['products'] })
},
Use optimistic patterns only when rollback is straightforward and conflicts are rare. Financial transactions or inventory with strict concurrency usually need pessimistic UI (disable button until server confirms) instead.
Infinite queries and pagination
useInfiniteQuery loads paginated data as a flat list of pages:
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['products', 'infinite'],
queryFn: ({ pageParam }) =>
fetch(`/api/products?cursor=${pageParam}`).then((r) => r.json()),
initialPageParam: null,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
})
// Render: data.pages.flatMap((page) => page.items)
Pair with intersection observers or “Load more” buttons. For offset/limit
APIs, pass page numbers as pageParam. Cursor-based pagination scales
better on large tables because skipping deep offsets is expensive on the database.
SSR and prefetching
Server-rendered apps should prefetch on the server, dehydrate the cache, and hydrate on the client to avoid a loading flash. In Next.js App Router, a typical pattern:
- Create a
QueryClientper request on the server. await queryClient.prefetchQuery({ queryKey, queryFn })in a Server Component or route loader.- Pass dehydrated state via
dehydrate(queryClient)to a client provider. - Client calls
HydrationBoundarywith that state before renderinguseQueryhooks.
TanStack Query complements — does not replace — Next.js native
fetch caching. Use Next.js fetch cache for static marketing pages;
use TanStack Query when the client needs interactive refetch, mutations, and
shared cache across client-navigated routes. Remix loaders can prefetch similarly
before hydration.
Worked example: Harbor Commerce product catalog
Harbor Commerce runs a B2B hardware catalog with category filters, SKU search, and cart mutations. The TanStack Query layout:
- Global defaults —
staleTime: 30_000for catalog lists (prices update frequently);staleTime: 5 * 60_000for static category metadata. - Product list query — key
['products', { category, q, page }];placeholderData: keepPreviousData(viaplaceholderData: (prev) => previn v5) so filter changes do not flash empty tables. - Product detail — key
['products', sku]; prefetched on list row hover withqueryClient.prefetchQueryto make detail pages feel instant. - Cart mutation —
useMutationPOSTs line items; optimistic update on['cart', sessionId];onSettledinvalidates cart and inventory availability queries. - Auth-gated queries — order history uses
enabled: !!session?.userId; keys includeuserIdso logout clears sensitive cache viaqueryClient.removeQueries. - Error boundaries — global
QueryCacheonErrorlogs 5xx to observability; 401 triggers auth refresh mutation before retry.
Devtools stay enabled only in staging. Production bundles tree-shake devtools.
E2E tests stub queryFn via MSW (Mock Service Worker) rather than
hitting the real catalog API, keeping CI deterministic.
Library decision table
| Choose TanStack Query when… | Consider SWR when… | Skip both when… |
|---|---|---|
| React or Vue app with many read/write server resources | Minimal API surface, Vercel ecosystem, smallest bundle | Data is static at build time (SSG only, no client refetch) |
| Mutations with cache invalidation are central | Read-heavy dashboards with simple revalidation | Real-time push via WebSockets replaces polling entirely |
| Infinite scroll and prefetch patterns matter | Team already standardized on SWR hooks | Server Components fetch everything; zero client interactivity |
| Devtools and mature mutation lifecycle hooks needed | Bundle size is the top constraint | GraphQL with Apollo Client normalized cache is already in place |
| RTK Query is overkill without Redux elsewhere | One-off fetch on mount with no sharing across routes |
Common pitfalls
- Unstable query keys — inline objects recreated every render (
{ category }without memoization) cause cache misses; keys must be structurally stable or serialized consistently. - Fetching in useEffect anyway — duplicating TanStack Query with parallel
useEffectfetches defeats deduplication and creates race conditions. - staleTime: 0 everywhere — refetches on every mount and focus; tune per resource freshness requirements.
- Missing error throws in queryFn — returning
undefinedon 404 without throwing marks the query successful with empty data. - Over-invalidation — invalidating
['products']after unrelated settings changes causes unnecessary network churn. - Optimistic updates without rollback — always capture
previousinonMutateand restore inonError. - Singleton QueryClient in SSR — sharing one client across requests leaks user data; instantiate per request on the server.
- Ignoring enabled dependencies — firing queries with
userId: undefinedhits public endpoints or throws; gate withenabled.
Practitioner checklist
- Define a query-key factory module (
productKeys.list(filters),productKeys.detail(id)) shared across hooks. - Set sensible global
staleTimeandgcTime; override per query for volatile vs static data. - Throw on non-OK HTTP responses inside
queryFn; map errors in UI withisErrorand retry buttons. - Wire mutations with
onSuccess/onSettledinvalidation targeting the narrowest key prefix. - Prefetch detail routes on hover or in route loaders for perceived performance.
- Use
enabledfor auth-dependent and param-gated queries. - Enable Devtools in development; verify cache state during filter and mutation flows.
- For SSR, dehydrate/hydrate per request; never share server QueryClient instances across users.
- Test with MSW or stubbed
queryFnin unit tests; assert loading and error UI states. - Document which resources are TanStack Query vs local state to prevent duplicate sources of truth.
Key takeaways
- TanStack Query manages server state — async data that can go stale — separately from UI state.
- Query keys are cache identifiers; hierarchical arrays enable targeted invalidation.
- staleTime and gcTime tune freshness vs memory; defaults should match product requirements.
- Mutations coordinate writes with cache invalidation or optimistic patches.
- SSR hydration eliminates loading flashes when prefetching is done correctly on the server.
Related reading
- React fundamentals explained — components, hooks, and local state
- Frontend state management explained — when server state belongs in TanStack Query vs global stores
- Next.js fundamentals explained — App Router, Server Components, and fetch caching
- TypeScript fundamentals explained — typing query functions and API responses