agentsclimarketplace

Tanstack query

Skill jgamaraalv/ts-dev-kit/skills/tanstack-query

TanStack Query v5 (React Query) reference for data fetching, caching, and server state management in React. Use when: (1) writing useQuery, useMutation, or useInfiniteQuery hooks, (2) setting up QueryClient and queryOptions, (3) implementing optimistic updates or cache invalidation, (4) configuring SSR/hydration with Next.js App Router or Pages Router, (5) testing React Query hooks, (6) working with TypeScript types, Suspense, or advanced patterns like dependent queries and infinite scroll.From its SKILL.md

Install
npx -y skills add jgamaraalv/ts-dev-kit --skill tanstack-query

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 15 stars15 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

11.0 KB, as published. Nobody here has run it

TanStack Query v5 (React)

<quick_reference>

Setup

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>
  )
}

Important Defaults

DefaultValueNotes
staleTime0Cached data is immediately stale; triggers background refetch on mount/focus/reconnect
gcTime5 minInactive queries garbage collected after 5 minutes
retry3 (queries) / 0 (mutations)Queries retry 3x with exponential backoff; mutations do NOT retry
refetchOnWindowFocustrueStale queries refetch when tab regains focus
refetchOnReconnecttrueStale queries refetch when network reconnects
refetchOnMounttrueStale queries refetch when new instance mounts
structuralSharingtruePreserves 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>

<examples>

queryOptions — co-locate key + fn

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).

useQuery

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).

Query Keys

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.

useMutation

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.

Query Invalidation

// 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.

Optimistic Updates

Approach 1: Via the UI (simpler, recommended)

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,
})

Approach 2: Via cache (with rollback)

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.

Infinite Queries

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.

Paginated Queries (keep previous data)

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),
})

Prefetching

// 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
})

Dependent Queries

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.

</examples> <gotchas>

setQueryData — immutability

// 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
)
</gotchas> <references>

Further Reference

</references>

Keep looking

Skills are one crate of 627,801. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.