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 flagsisPending (no data yet), isFetching (any fetch including background), isError, isSuccess.
  • enabled — set enabled: !!userId to 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:

  1. Create a QueryClient per request on the server.
  2. await queryClient.prefetchQuery({ queryKey, queryFn }) in a Server Component or route loader.
  3. Pass dehydrated state via dehydrate(queryClient) to a client provider.
  4. Client calls HydrationBoundary with that state before rendering useQuery hooks.

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:

  1. Global defaultsstaleTime: 30_000 for catalog lists (prices update frequently); staleTime: 5 * 60_000 for static category metadata.
  2. Product list query — key ['products', { category, q, page }]; placeholderData: keepPreviousData (via placeholderData: (prev) => prev in v5) so filter changes do not flash empty tables.
  3. Product detail — key ['products', sku]; prefetched on list row hover with queryClient.prefetchQuery to make detail pages feel instant.
  4. Cart mutationuseMutation POSTs line items; optimistic update on ['cart', sessionId]; onSettled invalidates cart and inventory availability queries.
  5. Auth-gated queries — order history uses enabled: !!session?.userId; keys include userId so logout clears sensitive cache via queryClient.removeQueries.
  6. Error boundaries — global QueryCache onError logs 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 resourcesMinimal API surface, Vercel ecosystem, smallest bundleData is static at build time (SSG only, no client refetch)
Mutations with cache invalidation are centralRead-heavy dashboards with simple revalidationReal-time push via WebSockets replaces polling entirely
Infinite scroll and prefetch patterns matterTeam already standardized on SWR hooksServer Components fetch everything; zero client interactivity
Devtools and mature mutation lifecycle hooks neededBundle size is the top constraintGraphQL with Apollo Client normalized cache is already in place
RTK Query is overkill without Redux elsewhereOne-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 useEffect fetches 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 undefined on 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 previous in onMutate and restore in onError.
  • 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: undefined hits public endpoints or throws; gate with enabled.

Practitioner checklist

  • Define a query-key factory module (productKeys.list(filters), productKeys.detail(id)) shared across hooks.
  • Set sensible global staleTime and gcTime; override per query for volatile vs static data.
  • Throw on non-OK HTTP responses inside queryFn; map errors in UI with isError and retry buttons.
  • Wire mutations with onSuccess / onSettled invalidation targeting the narrowest key prefix.
  • Prefetch detail routes on hover or in route loaders for perceived performance.
  • Use enabled for 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 queryFn in 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