From ts-dev-kit
Provides TanStack Query v5 reference for React data fetching, caching, server state management using useQuery/useMutation hooks, QueryClient setup, optimistic updates, Next.js SSR/hydration, testing, TypeScript, and advanced patterns.
How this skill is triggered — by the user, by Claude, or both
Slash command
/ts-dev-kit:tanstack-queryThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
<quick_reference>
<quick_reference>
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 min (default is 0)
gcTime: 5 * 60 * 1000, // 5 min (default)
retry: 3, // 3 retries with exponential backoff (default)
refetchOnWindowFocus: true, // default
},
},
})
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
)
}
| Default | Value | Notes |
|---|---|---|
staleTime | 0 | Cached data is immediately stale; triggers background refetch on mount/focus/reconnect |
gcTime | 5 min | Inactive queries garbage collected after 5 minutes |
retry | 3 (queries) / 0 (mutations) | Queries retry 3x with exponential backoff; mutations do NOT retry |
refetchOnWindowFocus | true | Stale queries refetch when tab regains focus |
refetchOnReconnect | true | Stale queries refetch when network reconnects |
refetchOnMount | true | Stale queries refetch when new instance mounts |
structuralSharing | true | Preserves referential identity if data is structurally equal |
Key recommendation: Set staleTime above 0 to control refetch frequency rather than disabling individual refetch triggers.
</quick_reference>
Always use queryOptions to define query configurations. It enables type inference across useQuery, prefetchQuery, getQueryData, and setQueryData.
import { queryOptions, infiniteQueryOptions } from '@tanstack/react-query'
export function todosOptions(filters: TodoFilters) {
return queryOptions({
queryKey: ['todos', filters],
queryFn: () => fetchTodos(filters),
staleTime: 5 * 1000,
})
}
// Works everywhere with full type inference:
useQuery(todosOptions({ status: 'done' }))
useSuspenseQuery(todosOptions({ status: 'done' }))
queryClient.prefetchQuery(todosOptions({ status: 'done' }))
queryClient.setQueryData(todosOptions({ status: 'done' }).queryKey, newData)
const cached = queryClient.getQueryData(todosOptions({ status: 'done' }).queryKey)
// ^? TodoItem[] | undefined
For infinite queries, use infiniteQueryOptions (same pattern, adds initialPageParam and getNextPageParam).
const {
data, // TData | undefined
error, // TError | null
status, // 'pending' | 'error' | 'success'
isPending, // no cached data yet
isError,
isSuccess,
isFetching, // queryFn is running (including background refetch)
isLoading, // isPending && isFetching (first load only)
isPlaceholderData, // showing placeholder, not real data
isStale,
refetch,
fetchStatus, // 'fetching' | 'paused' | 'idle'
} = useQuery({
queryKey: ['todos', userId], // unique cache key (Array)
queryFn: () => fetchTodos(userId),
enabled: !!userId, // disable until userId exists
staleTime: 60_000,
select: (data) => data.filter(t => !t.done), // transform/filter
placeholderData: keepPreviousData, // smooth pagination
})
Query states: status tells you "do we have data?"; fetchStatus tells you "is the queryFn running?". They are orthogonal — a query can be pending + paused (no data, no network).
Keys must be Arrays. They are hashed deterministically.
// Object key order does NOT matter — these are equivalent:
useQuery({ queryKey: ['todos', { status, page }] })
useQuery({ queryKey: ['todos', { page, status }] })
// Array item order DOES matter — these are different:
useQuery({ queryKey: ['todos', status, page] })
useQuery({ queryKey: ['todos', page, status] })
Rule: If your queryFn depends on a variable, include it in the queryKey. The key acts as a dependency array.
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: (newTodo: CreateTodoInput) => api.post('/todos', newTodo),
onSuccess: (data, variables) => {
// Invalidate related queries to trigger refetch
queryClient.invalidateQueries({ queryKey: ['todos'] })
// Or update cache directly with response data
queryClient.setQueryData(['todos', data.id], data)
},
onError: (error, variables, onMutateResult) => {},
onSettled: (data, error, variables, onMutateResult) => {},
})
// Trigger:
mutation.mutate({ title: 'New todo' })
// Or with per-call callbacks:
mutation.mutate(input, { onSuccess: () => navigate('/todos') })
// Async variant (returns Promise):
const data = await mutation.mutateAsync(input)
Lifecycle: onMutate → mutationFn → onSuccess/onError → onSettled. Callbacks returning promises are awaited.
Gotcha: Per-call mutate() callbacks only fire for the latest call if mutations overlap. Use useMutation-level callbacks for reliable logic.
// Prefix match (default) — invalidates ['todos'] and ['todos', { page: 1 }]
queryClient.invalidateQueries({ queryKey: ['todos'] })
// Exact match only
queryClient.invalidateQueries({ queryKey: ['todos'], exact: true })
// Predicate for fine-grained control
queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'todos' && query.queryKey[1]?.version >= 10,
})
// ALL queries
queryClient.invalidateQueries()
Invalidation marks queries as stale and triggers background refetch for active (rendered) queries.
Render optimistic state from variables directly in JSX:
const { mutate, variables, isPending, isError } = useMutation({
mutationFn: (text: string) => api.post('/todos', { text }),
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
})
// In JSX:
{todos.map(todo => <li key={todo.id}>{todo.text}</li>)}
{isPending && <li style={{ opacity: 0.5 }}>{variables}</li>}
Access pending mutations from other components with useMutationState:
const pendingTodos = useMutationState<string>({
filters: { mutationKey: ['addTodo'], status: 'pending' },
select: (mutation) => mutation.state.variables,
})
useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo, context) => {
await context.client.cancelQueries({ queryKey: ['todos'] })
const previous = context.client.getQueryData(['todos'])
context.client.setQueryData(['todos'], (old) => [...old, newTodo])
return { previous }
},
onError: (err, newTodo, onMutateResult, context) => {
context.client.setQueryData(['todos'], onMutateResult.previous)
},
onSettled: (data, error, variables, onMutateResult, context) => {
context.client.invalidateQueries({ queryKey: ['todos'] })
},
})
Always cancelQueries before optimistic update to prevent background refetches from overwriting.
const {
data, // { pages: T[], pageParams: unknown[] }
fetchNextPage,
fetchPreviousPage,
hasNextPage, // true when getNextPageParam returns non-null/undefined
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
} = useInfiniteQuery({
queryKey: ['projects'],
queryFn: ({ pageParam }) => fetchProjects(pageParam),
initialPageParam: 0, // REQUIRED
getNextPageParam: (lastPage, allPages) => lastPage.nextCursor ?? undefined,
maxPages: 3, // optional: cap cached pages
})
// Render all pages:
{data.pages.map((page, i) => (
<Fragment key={i}>
{page.items.map(item => <div key={item.id}>{item.name}</div>)}
</Fragment>
))}
// Load more:
<button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage}>
{isFetchingNextPage ? 'Loading...' : hasNextPage ? 'Load More' : 'No more'}
</button>
Gotcha: data is { pages, pageParams }, not flat data. initialData and placeholderData must match this shape.
import { keepPreviousData, useQuery } from '@tanstack/react-query'
const { data, isPlaceholderData } = useQuery({
queryKey: ['projects', page],
queryFn: () => fetchProjects(page),
placeholderData: keepPreviousData,
})
// Prefetch next page for instant transitions:
queryClient.prefetchQuery({
queryKey: ['projects', page + 1],
queryFn: () => fetchProjects(page + 1),
})
// In event handlers (hover/focus):
const prefetch = () => queryClient.prefetchQuery(todosOptions())
<button onMouseEnter={prefetch} onFocus={prefetch} onClick={handleClick}>Show</button>
// In components (avoid Suspense waterfalls):
function Layout({ id }: { id: string }) {
usePrefetchQuery(commentsOptions(id)) // starts fetch immediately
return (
<Suspense fallback="Loading...">
<Article id={id} />
</Suspense>
)
}
// Prefetch infinite queries:
queryClient.prefetchInfiniteQuery({
...projectsInfiniteOptions(),
pages: 3, // prefetch first 3 pages
})
const { data: user } = useQuery({
queryKey: ['user', email],
queryFn: () => getUserByEmail(email),
})
const { data: projects } = useQuery({
queryKey: ['projects', user?.id],
queryFn: () => getProjectsByUser(user!.id),
enabled: !!user?.id, // waits for user query
})
Type-safe disabling with skipToken:
import { skipToken } from '@tanstack/react-query'
const { data } = useQuery({
queryKey: ['projects', userId],
queryFn: userId ? () => getProjects(userId) : skipToken,
})
skipToken prevents refetch() from working — use enabled: false if you need manual refetch.
// WRONG — mutating cache in place
queryClient.setQueryData(['todo', id], (old) => {
if (old) old.title = 'new' // DO NOT DO THIS
return old
})
// CORRECT — return new object
queryClient.setQueryData(['todo', id], (old) =>
old ? { ...old, title: 'new' } : old
)
npx claudepluginhub jgamaraalv/ts-dev-kit --plugin ts-dev-kitManages server state with automatic caching, background refetching, pagination, infinite scrolling, and optimistic updates for React, Vue, Svelte, Angular apps.
Expert in TanStack Query (React Query) for async state management, data fetching, cache invalidation, mutations, optimistic updates, and Next.js App Router SSR integration.
Optimizes TanStack Query v5 patterns for data fetching, caching, mutations, prefetching, infinite queries, Suspense, error/retry handling, and render performance.