Patterns

Patterns — TanStack Query

Read → write → invalidate loop

When to use: the default shape for almost every feature (list + create/edit/delete form). How: useQuery for the read; useMutation for the write; call queryClient.invalidateQueries({ queryKey }) from the mutation's onSuccess (returning/awaiting the promise if the UI should wait for the refetch, not just the write). Trade-offs: simplest to reason about; costs one extra round trip per mutation vs. writing the response directly into the cache.

Direct cache write from a mutation response

When to use: the mutation's response already is the fresh data. How: queryClient.setQueryData(key, data) in onSuccess, immutably. Trade-offs: saves a network call; only works when the response shape matches the query's cached shape exactly.

Optimistic update — via the UI

When to use: the pending result only needs to show in one place. How: read mutation.variables/isPending off the useMutation result and render a temporary item; invalidate in onSettled. Trade-offs: no rollback code needed at all; doesn't help if multiple UI locations need to reflect the change.

Optimistic update — via the cache

When to use: multiple UI locations must reflect the pending change. How: in onMutate, cancelQueries → snapshot with getQueryDatasetQueryData the optimistic value → return the snapshot; in onError, restore it; in onSettled, invalidate. Trade-offs: propagates everywhere automatically; requires full rollback logic and careful cancellation.

Dependent query chain

When to use: query B genuinely needs a value from query A's result. How: enabled: !!derivedValue on query B. Trade-offs: simple, but every dependent chain is a request waterfall — check whether a combined backend endpoint could flatten it first.

Consolidating parallel Suspense queries

When to use: more than one useSuspenseQuery lives in the same component. How: replace with one useSuspenseQueries({ queries: [...] }) call. Trade-offs: restores true parallelism; loses the ability to destructure named variables directly (returns an array).

Prefetch ahead of a Suspense boundary

When to use: secondary data shouldn't block the primary boundary's fallback. How: usePrefetchQuery/usePrefetchInfiniteQuery in a component above the <Suspense> boundary; consume with useSuspenseQuery inside, optionally in its own nested boundary. Trade-offs: avoids blocking; adds an extra component layer to reason about.

Server prefetch + hydration

When to use: SSR/SSG pages where initial content should ship pre-fetched. How: in a loader/Server Component, await queryClient.query(...), then dehydrate(queryClient)<HydrationBoundary state={...}> on the client, with a non-zero staleTime to avoid an immediate client refetch. Trade-offs: eliminates client-side loading flash; requires a per-request QueryClient (never module-scoped) and care with gcTime on the server.

Persisting the cache across sessions

When to use: offline-tolerant apps, or avoiding a cold cache on reload. How: whole-client via persistQueryClient/PersistQueryClientProvider (raise gcTime to match maxAge), or per-query via experimental_createQueryPersister when only some queries are worth persisting. Trade-offs: whole-client is simpler; per-query gives finer control (different maxAge per query, independent of in-memory gcTime) at the cost of more setup.

Cross-component optimistic UI via useMutationState

When to use: the mutation trigger and the UI that should reflect it live in different components. How: tag the mutation with a mutationKey; elsewhere, useMutationState({ filters: { mutationKey, status: 'pending' }, select: (m) => m.state.variables }). Trade-offs: avoids prop drilling / a shared cache write; couples both sides to the same mutationKey string.

ESLint-enforced defaults

When to use: any new project. How: pluginQuery.configs['flat/recommended'] (or -strict for prefer-query-options too). Trade-offs: catches most of the patterns above automatically (exhaustive-deps, no-rest-destructuring, stable-query-client) — cheap insurance against the most common regressions.