---
name: tanstack-query-pro
description: "TanStack Query (React Query) — queries, mutations, optimistic updates, infinite scroll, prefetch. Production TanStack Query: queryKey discipline, staleTime vs gcTime, invalidations, mutations with optimistic updates and rollback, infinite queries, useSuspenseQuery for RSC, server-side prefetching with hydration."
license: MIT
---

# TanStack Query Pro

A practitioner's playbook for building data layers with TanStack Query (formerly React Query) v5. This skill encodes the conventions that keep a query cache predictable at scale: stable keys, deliberate cache lifetimes, surgical invalidation, and mutations that update the UI before the server replies. It's aimed at React developers past the "it works on my machine" stage who are wrestling with stale data, race conditions, or duplicate requests in production.

## When to use it

- You have `queryKey` arrays scattered as string literals and invalidation has become guesswork.
- A list shows stale items after an edit, or a mutation flickers between optimistic and real state.
- You're building infinite scroll, search-as-you-type, or paginated tables and need `keepPreviousData` behavior.
- You're moving to the App Router / RSC and want server-prefetched data hydrated into the client cache without a flash.

## How it works

The skill works against the real v5 API surface. It standardizes `queryKey` factories (e.g. `todos.list(filters)` returning `['todos', 'list', filters]`) so keys stay serializable and collision-free. It clarifies the two timers people conflate: `staleTime` (how long data is considered fresh before a background refetch) versus `gcTime` (how long unused cache entries survive before garbage collection). For writes, it sets up `useMutation` with the `onMutate` → `onError` → `onSettled` lifecycle: snapshot via `getQueryData`, write via `setQueryData`, roll back on failure, then `invalidateQueries` to reconcile. It covers `useInfiniteQuery` with `getNextPageParam`, `useSuspenseQuery` for Suspense/RSC boundaries, and server prefetch via `prefetchQuery` + `dehydrate`/`HydrationBoundary`.

## Example

```tsx
const useUpdateTodo = () => {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: patchTodo,
    onMutate: async (next) => {
      await qc.cancelQueries({ queryKey: ['todos'] })
      const prev = qc.getQueryData(['todos'])
      qc.setQueryData(['todos'], (old) => old.map(t => t.id === next.id ? next : t))
      return { prev }
    },
    onError: (_e, _next, ctx) => qc.setQueryData(['todos'], ctx.prev),
    onSettled: () => qc.invalidateQueries({ queryKey: ['todos'] }),
  })
}
```

## Tips & gotchas

- `cancelQueries` inside `onMutate` is not optional — skip it and an in-flight refetch can clobber your optimistic write.
- `staleTime` defaults to `0`, so every mount refetches. Raise it for data that doesn't change per-view.
- `invalidateQueries({ queryKey: ['todos'] })` matches by prefix, invalidating every key that starts with `todos`. Use this rather than removing entries.
- With `useSuspenseQuery`, `data` is never `undefined` and there is no `isLoading` — handle the pending state with a `<Suspense>` boundary instead.

## FAQ

**staleTime vs gcTime?** `staleTime` governs freshness (when to refetch); `gcTime` (was `cacheTime`) governs how long an inactive query stays in memory. They're independent.

**How do I keep the old page visible during pagination?** Use the `placeholderData: keepPreviousData` helper in v5 so the previous result renders while the next page loads.

**Does prefetched server data avoid a client refetch?** Yes, if you set `staleTime` so the hydrated data is still fresh on mount; otherwise it refetches immediately.
