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.
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.
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.
When to use: multiple UI locations must reflect the pending change.
How: in onMutate, cancelQueries → snapshot with getQueryData → setQueryData 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.
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.
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).
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.
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.
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.
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.
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.