.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) andfetchStatus(fetching | paused | idle, about the queryFn). Background refetch =success+fetching. - Server state lives ONLY in the cache. Never copy
dataintouseState. Forms are the one exception — see Forms below.
Query keys
- 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. - tRPC keys are
[path, { input, type }]. Prefix matching dies at the input element:queryOptions({})only matches entries queried with input{}. - To invalidate ALL inputs of a procedure use
.pathFilter()(trpc.order.getPaginatedOrders.pathFilter()). To match all inputs with a raw key usetrpc.x.y.queryKey()with no input argument. - 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.) - 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
setQueryDatawhen 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?)fromsrc/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-callmutate(vars, { onSuccess })callbacks — those may be skipped if the component unmounts, which is correct for UI-only effects. - Prefer
mutateovermutateAsync. UsemutateAsynconly 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 ownonError. So: if you add a localonError, you own the toast too. A no-oponError: () => {}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.variableswhilemutation.isPending; let settle-time invalidation refresh everything. No cache surgery, automatic revert on failure. - If you truly need cache-level optimism:
cancelQueries→ snapshot viagetQueriesData→setQueryData→ return{ previous }fromonMutate→ restore inonError. Gate settle invalidation withisMutating({ mutationKey }) === 1under concurrency.
Rendering rules
- Check
dataFIRST, 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 withuseCallback. - Paginated lists:
placeholderData: keepPreviousDatato avoid flash-between- pages. Infinite lists: alwaysmaxPages— refetch re-runs every cached page.
Abstraction
queryOptions()(tRPC proxy provides these) is THE abstraction. It composes intouseQuery,useSuspenseQuery, route loaders,ensureQueryData, imperative calls — and DataTags types throughgetQueryData.- 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>. Noas nevercasts at call sites — if inference fails, fix the source types. - Route loaders prefetch (fire-and-forget
ensureQueryData/prefetchQuery), components consume viauseSuspenseQuery. Never await in the loader; never consumeRoute.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 ondata !== undefined.
v5 gotchas
cacheTime→gcTime; queryisLoading→isPending(isLoadingnow meansisPending && isFetching).- Object-signature APIs only:
invalidateQueries({ queryKey, exact }). - No
onSuccess/onError/onSettledonuseQuery— moved to mutations or the global QueryCache. - Infinite queries require
initialPageParam;keepPreviousDataoption becameplaceholderData: keepPreviousData. - Mutations don't retry by default but queue offline and replay on reconnect.