Engineering Handbook
Frontend

TanStack Query

Comprehensive TanStack Query best practices based on TkDodo's full series.

TanStack Query

TanStack Query is our default async server-state manager for React apps. This guide consolidates practical team conventions from TkDodo's complete series (all 32 entries, including #8a) and adapts them to TanStack Query naming and v5-style usage.

What it is

  • Treat TanStack Query as server-state orchestration, not as a generic local state store.
  • Keep server state and client state separate; copy server data into local state only when deliberate (for example, form initialization).
  • Think declaratively: query keys drive fetches; avoid imperative "refetch with different params".
  • Prefer tuning staleTime over disabling useful defaults like focus/reconnect refetch.
  • Use custom hooks or queryOptions factories to keep query key + query function + options colocated.

Best practices

Why we use it

  • Use array keys and structure from broad to specific, e.g. ['todos', 'list', filters], ['todos', 'detail', id].
  • Include every query dependency in the key.
  • Never share the same key between useQuery and useInfiniteQuery.
  • Prefer key factories per feature and colocate them with query code.
  • For complex apps, consider object-style keys for clearer fuzzy matching and context typing.

Setup in this repo

  • Understand default behavior (staleTime: 0, background refetch on mount/focus/reconnect).
  • Show stale data first when reasonable; avoid replacing useful stale UI with full-screen errors.
  • Use gcTime as a cache cleanup control; change it rarely.
  • For websocket-driven or push-updated UIs, increase staleTime significantly (sometimes Infinity) and invalidate explicitly on events.
  • Use enabled for dependent queries and "wait for user input" workflows.

Team conventions

  • Prefer select for data transformation and observer-level subscriptions.
  • Keep selectors stable (useCallback or extracted function) when transformations are expensive.
  • For heavy transforms used by many observers, add external memoization around the transform.
  • Use select for partial subscriptions to reduce rerenders without splitting cache entries.

Error handling and status checks

  • Ensure query functions throw rejected promises for real failures (fetch users must throw on !response.ok).
  • In many UX flows, check for data first, then error, then pending/loading.
  • Use throwOnError with Error Boundaries for coarse-grained handling.
  • Use global QueryCache/MutationCache callbacks for one-time toast/monitoring side effects.

Mutations and invalidation

  • Prefer invalidation as the default post-mutation strategy; direct cache writes only when response data is authoritative and easy to merge.
  • Keep mutation logic-side effects (invalidation/cache updates) in mutation callbacks; keep UI side effects near UI call sites.
  • Return/await invalidation promises only when UX needs mutation to remain pending until refetch finishes.
  • For optimistic updates, cancel overlapping queries before optimistic writes.
  • In concurrent optimistic scenarios, avoid over-invalidation by skipping redundant invalidations when related mutations are still running.

TypeScript safety patterns

  • Let inference work: type API layer return values, avoid manually supplying useQuery generics unless required.
  • Narrow unknown errors (instanceof Error) and runtime-validate API payloads for critical paths (for example with schemas).
  • Prefer queryOptions for reusable type-safe query definitions.
  • Use strongly typed key/query factories for consistent key usage and safer QueryFunctionContext.

Forms, router, offline, and infinite queries

  • Router loaders + TanStack Query: pre-seed cache early in loaders, then read with query hooks in components.
  • Forms: either
    • treat server values as initial defaults and disable unnecessary background updates, or
    • keep server + draft states separate and derive displayed values.
  • Offline/network modes: choose online, always, or offlineFirst intentionally per query/mutation behavior.
  • Seeding detail caches: use pull (initialData from list cache + initialDataUpdatedAt) or push approaches based on list size/churn.

Testing guidelines

  • Use one QueryClient per test for isolation.
  • Disable retries in tests unless explicitly testing retry behavior.
  • Prefer network-level mocking (for example MSW) over client implementation mocking.
  • Always await settled query states before asserting.

Abstractions and anti-patterns

  • Keep TanStack Query config shared and stable via a single app-level QueryClientProvider.
  • Use feature-local query factories (queryOptions + key helpers) instead of global key constants.
  • Default to invalidation over manual cache surgery.
  • Use select only when needed for real rendering/perf benefits.
  • Keep docs/code terminology consistent: prefer TanStack Query over legacy "React Query".

Example

import { useQuery } from "@tanstack/react-query";

export function useProjectsQuery() {
  return useQuery({
    queryKey: ["projects"],
    queryFn: async () => {
      const res = await fetch("/api/projects");
      if (!res.ok) throw new Error("Failed to fetch projects");
      return res.json();
    },
  });
}

Common pitfalls

  • Query keys and query function dependencies drifting out of sync.
  • Treating query cache as local editable state.
  • Overusing optimistic updates for complex flows.
  • Recreating QueryClient accidentally and losing cache state.
  • Using initialData where placeholderData is intended.
  • Hiding fetch errors by returning resolved promises from queryFn catch blocks.

References

Complete TkDodo series

Foundational Patterns

Keys, Context, Data Seeding, and State Design

Advanced Type Safety, API Design, and Performance

On this page