.react-query-best-practices.md

React Query best practices — vit-store admin

Temp reference doc. Not for committing. Sources: official TanStack Query v5 docs, TkDodo's "Practical React Query" series (#1–32), plus conventions established in PRs #273–#276.

Mental model

  • React Query is an async state manager, not a fetch helper.
  • Two state machines per query: status (pending | error | success, about the data) and fetchStatus (fetching | paused | idle, about the queryFn). Background refetch = success + fetching.
  • Server state lives ONLY in the cache. Never copy data into useState. Forms are the one exception — see Forms below.

Query keys

  1. Keys are dependency arrays. Any variable the queryFn reads goes IN the key. Change the key (state / search params), never call fetch imperatively. refetch(params) does not exist by design.
  2. tRPC keys are [path, { input, type }]. Prefix matching dies at the input element: queryOptions({}) only matches entries queried with input {}.
  3. To invalidate ALL inputs of a procedure use .pathFilter() (trpc.order.getPaginatedOrders.pathFilter()). To match all inputs with a raw key use trpc.x.y.queryKey() with no input argument.
  4. NEVER spread a query key into filters: { ...trpc.x.y.queryKey } produces {0:'x', 1:'y'} which invalidates the entire cache. (This bug shipped once already — order-form.tsx.)
  5. Raw string keys are allowed for non-tRPC queries (e.g. ["admin-products-infinite", pageSize, ...filters]). Keep them hierarchical, most-generic first, so prefix invalidation works.

Invalidation & mutations

  • Default to invalidate-after-mutate; only setQueryData when the server echoes the full entity back AND you update every cache that holds it.
  • Invalidation ≠ refetch. Only active observers refetch; inactive entries are just marked stale.
  • One product changed → call invalidateProductCaches(qc, productId?) from src/utils/product-cache.ts. It covers: admin-products-infinite, searchProductsInstant, getAllProducts, getProductById. Do not hand-pick caches; partial patching is how stale stock/price leaked between views before #275.
  • Cache logic (invalidation) belongs in useMutation({...}) callbacks. UI actions (redirects, toasts on success, closing dialogs) belong in per-call mutate(vars, { onSuccess }) callbacks — those may be skipped if the component unmounts, which is correct for UI-only effects.
  • Prefer mutate over mutateAsync. Use mutateAsync only for sequential deps or batch fan-out with try/catch per item.
  • Global error toast lives in the MutationCache({ onError }) in trpc.ts. It skips mutations that define their own onError. So: if you add a local onError, you own the toast too. A no-op onError: () => {} suppresses the global toast (used for batch flows that show one summary toast).

Optimistic updates (rarely)

  • Only worth it for high-frequency, near-guaranteed-success interactions (stock steppers). NOT for price edits or form saves — a spinner is honest and cheap. Price edits deliberately have no optimism.
  • Preferred variant (what product-card uses now): render mutation.variables while mutation.isPending; let settle-time invalidation refresh everything. No cache surgery, automatic revert on failure.
  • If you truly need cache-level optimism: cancelQueries → snapshot via getQueriesData → setQueryData → return { previous } from onMutate → restore in onError. Gate settle invalidation with isMutating({ mutationKey }) === 1 under concurrency.

Rendering rules

  • Check data FIRST, then error, then pending. Background refetch errors should not wipe good stale data (retries + focus refetch mean an error often arrives seconds after usable data was on screen).
  • Don't rest-destructure the query result (const { isLoading, ...rest }) — it defeats tracked-queries re-render optimization.
  • Transform data with select, not in render. Inline selectors are fine; stabilize expensive ones with useCallback.
  • Paginated lists: placeholderData: keepPreviousData to avoid flash-between- pages. Infinite lists: always maxPages — refetch re-runs every cached page.

Abstraction

  • queryOptions() (tRPC proxy provides these) is THE abstraction. It composes into useQuery, useSuspenseQuery, route loaders, ensureQueryData, imperative calls — and DataTags types through getQueryData.
  • Custom hooks that merely wrap useQuery(...) are pointless in v5. Extract a hook only when it adds real logic.
  • Type the queryFn return, pass ZERO generics. Never write useQuery<Todo>. No as never casts at call sites — if inference fails, fix the source types.
  • Route loaders prefetch (fire-and-forget ensureQueryData/prefetchQuery), components consume via useSuspenseQuery. Never await in the loader; never consume Route.useLoaderData() instead of observing the query (unobserved entries go inactive and get GC'd).

Project defaults (utils/trpc.ts)

  • staleTime: 30s, gcTime: 5min, refetchOnWindowFocus: false, retry: 1.
  • Per-feature overrides at the call site are fine (orders list polls 60s, instant search has its own staleTime). Polling is a safety net here, not the primary refresh mechanism — invalidations are.
  • Fire-once background-refetch error toasts: global QueryCache({ onError }), gated on data !== undefined.

v5 gotchas

  • cacheTime → gcTime; query isLoading → isPending (isLoading now means isPending && isFetching).
  • Object-signature APIs only: invalidateQueries({ queryKey, exact }).
  • No onSuccess/onError/onSettled on useQuery — moved to mutations or the global QueryCache.
  • Infinite queries require initialPageParam; keepPreviousData option became placeholderData: keepPreviousData.
  • Mutations don't retry by default but queue offline and replay on reconnect.