.agents/skills/tanstack-query/SKILL.md
Powerful asynchronous state management, server-state utilities, and data fetching for SolidJS, TS/JS.
npx skillsauth add em-jones/staccato-toolkit tanstack-queryInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
TanStack Query (formerly React Query) manages server state - data that lives on the server and needs to be fetched, cached, synchronized, and updated. It provides automatic caching, background refetching, stale-while-revalidate patterns, pagination, infinite scrolling, and optimistic updates out of the box.
Package: @tanstack/solid-query
Devtools: @tanstack/solid-query-devtools
Current Version: v5
npm install @tanstack/solid-query
npm install -D @tanstack/solid-query-devtools # Optional
import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'
import { SolidQueryDevtools } from '@tanstack/solid-query-devtools'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute
gcTime: 1000 * 60 * 5, // 5 minutes (garbage collection)
retry: 3,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
},
},
})
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
<SolidQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
Query keys uniquely identify cached data. They must be serializable arrays:
// Simple key
useQuery(() => ({ queryKey: ['todos'], queryFn: fetchTodos }))
// With variables (dependency array pattern)
useQuery(() => ({ queryKey: ['todos', { status, page }], queryFn: fetchTodos }))
// Hierarchical keys for invalidation
useQuery(() => ({ queryKey: ['todos', todoId], queryFn: () => fetchTodo(todoId) }))
useQuery(() => ({ queryKey: ['todos', todoId, 'comments'], queryFn: () => fetchComments(todoId) }))
// Invalidation matches prefixes:
// queryClient.invalidateQueries({ queryKey: ['todos'] })
// ^ Invalidates ALL queries starting with 'todos'
// Query function receives a QueryFunctionContext
useQuery(() => ({
queryKey: ['todos', todoId],
queryFn: async ({ queryKey, signal, meta }) => {
const [_key, id] = queryKey
const response = await fetch(`/api/todos/${id}`, { signal })
if (!response.ok) throw new Error('Failed to fetch')
return response.json()
},
}))
// Using the signal for automatic cancellation
useQuery(() => ({
queryKey: ['todos'],
queryFn: async ({ signal }) => {
const response = await fetch('/api/todos', { signal })
return response.json()
},
}))
Create reusable, type-safe query configurations:
import { queryOptions } from '@tanstack/solid-query'
export const todosQueryOptions = queryOptions({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 5000,
})
export const todoQueryOptions = (todoId: number) =>
queryOptions({
queryKey: ['todos', todoId],
queryFn: () => fetchTodo(todoId),
enabled: !!todoId,
})
// Usage
const query = useQuery(todosQueryOptions)
const query = useQuery(() => todoQueryOptions(id))
await queryClient.prefetchQuery(todosQueryOptions)
import { useQuery } from '@tanstack/solid-query'
import { Switch, Match } from 'solid-js'
function Todos() {
const query = useQuery(() => ({
queryKey: ['todos'],
queryFn: fetchTodos,
}))
return (
<Switch>
<Match when={query.isLoading}>
<Spinner />
</Match>
<Match when={query.isError}>
<Error message={query.error?.message} />
</Match>
<Match when={query.isSuccess}>
<TodoList todos={query.data} />
</Match>
</Switch>
)
}
useQuery(() => ({
queryKey: ['todos'],
queryFn: fetchTodos,
// Freshness
staleTime: 5000, // ms data stays fresh (default: 0)
gcTime: 300000, // ms unused data stays in cache (default: 5 min)
// Refetching
refetchInterval: 10000, // Poll every 10s
refetchIntervalInBackground: false, // Don't poll when tab hidden
refetchOnMount: true, // Refetch on component mount if stale
refetchOnWindowFocus: true, // Refetch on window focus if stale
refetchOnReconnect: true, // Refetch on network reconnect
// Retry
retry: 3, // Number of retries (or function)
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
// Conditional
enabled: !!userId, // Only run when truthy
// Initial/placeholder data
initialData: () => cachedData,
initialDataUpdatedAt: Date.now() - 10000,
placeholderData: (previousData) => previousData, // keepPreviousData pattern
// Transform
select: (data) => data.filter(todo => !todo.done),
// Structural sharing (default: true)
structuralSharing: true,
// Network mode
networkMode: 'online', // 'online' | 'always' | 'offlineFirst'
// Meta (accessible in query function context)
meta: { purpose: 'user-facing' },
}))
import { useMutation, useQueryClient } from '@tanstack/solid-query'
function AddTodo() {
const queryClient = useQueryClient()
const mutation = useMutation(() => ({
mutationFn: (newTodo: { title: string }) => {
return fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
}).then(res => res.json())
},
// Lifecycle callbacks
onMutate: async (variables) => {
// Called before mutationFn
// Good for optimistic updates
return { previousTodos } // context for onError
},
onSuccess: (data, variables, context) => {
// Invalidate related queries
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
onError: (error, variables, context) => {
// Rollback optimistic updates
queryClient.setQueryData(['todos'], context.previousTodos)
},
onSettled: (data, error, variables, context) => {
// Always runs (success or error)
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
}))
return (
<button
onClick={() => mutation.mutate({ title: 'New Todo' })}
disabled={mutation.isPending}
>
{mutation.isPending ? 'Adding...' : 'Add Todo'}
</button>
)
}
const mutation = useMutation(() => ({
mutationFn: addTodo,
}))
// Access in reactive context
mutation.isPending // Mutation in progress
mutation.isError
mutation.isSuccess
mutation.isIdle // Not yet fired
mutation.data // Success response
mutation.error // Error object
mutation.reset // Reset state to idle
mutation.variables // Variables passed to mutate
mutation.status // 'idle' | 'pending' | 'error' | 'success'
// Fire mutation
mutation.mutate(newTodo)
const result = await mutation.mutateAsync(newTodo)
const mutation = useMutation(() => ({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
// 1. Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] })
// 2. Snapshot previous value
const previousTodo = queryClient.getQueryData(['todos', newTodo.id])
// 3. Optimistically update
queryClient.setQueryData(['todos', newTodo.id], newTodo)
// 4. Return context for rollback
return { previousTodo }
},
onError: (err, newTodo, context) => {
// Rollback on error
queryClient.setQueryData(['todos', newTodo.id], context.previousTodo)
},
onSettled: () => {
// Always refetch to sync with server
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
}))
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previousTodos = queryClient.getQueryData(['todos'])
queryClient.setQueryData(['todos'], (old) => [...old, newTodo])
return { previousTodos }
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previousTodos)
},
const queryClient = useQueryClient()
// Invalidate all queries
queryClient.invalidateQueries()
// Invalidate by prefix
queryClient.invalidateQueries({ queryKey: ['todos'] })
// Invalidate exact match
queryClient.invalidateQueries({ queryKey: ['todos', 1], exact: true })
// Invalidate with predicate
queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === 'todos' && query.queryKey[1]?.status === 'done',
})
// Invalidate and refetch immediately
queryClient.refetchQueries({ queryKey: ['todos'] })
// Remove from cache entirely
queryClient.removeQueries({ queryKey: ['todos', 1] })
// Reset to initial state
queryClient.resetQueries({ queryKey: ['todos'] })
import { useInfiniteQuery } from '@tanstack/solid-query'
import { For, Show } from 'solid-js'
function InfiniteList() {
const query = useInfiniteQuery(() => ({
queryKey: ['projects'],
queryFn: async ({ pageParam }) => {
const res = await fetch(`/api/projects?cursor=${pageParam}`)
return res.json()
},
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam) => {
return lastPage.nextCursor ?? undefined // undefined = no more pages
},
getPreviousPageParam: (firstPage, allPages, firstPageParam) => {
return firstPage.prevCursor ?? undefined
},
maxPages: 3, // Keep max 3 pages in cache (for performance)
}))
return (
<div>
<For each={query.data?.pages}>
{(page) => (
<For each={page.items}>
{(item) => <Item item={item} />}
</For>
)}
</For>
<button
onClick={() => query.fetchNextPage()}
disabled={!query.hasNextPage || query.isFetchingNextPage}
>
{query.isFetchingNextPage
? 'Loading...'
: query.hasNextPage
? 'Load More'
: 'No more'}
</button>
</div>
)
}
// Multiple independent queries run in parallel automatically
function Dashboard() {
const usersQuery = useQuery(() => ({ queryKey: ['users'], queryFn: fetchUsers }))
const projectsQuery = useQuery(() => ({ queryKey: ['projects'], queryFn: fetchProjects }))
// Both fetch simultaneously
}
// Dynamic parallel queries with useQueries
function UserProjects({ userIds }) {
const queries = useQueries(() => ({
queries: userIds.map((id) => ({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
})),
combine: (results) => ({
data: results.map(r => r.data),
pending: results.some(r => r.isPending),
}),
}))
}
// Sequential queries using enabled
function UserPosts({ userId }) {
const userQuery = useQuery(() => ({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
}))
const postsQuery = useQuery(() => ({
queryKey: ['posts', userId],
queryFn: () => fetchPostsByUser(userId),
enabled: () => !!userQuery.data, // Only run when user is loaded
}))
}
import { createSignal } from 'solid-js'
function PaginatedList() {
const [page, setPage] = createSignal(1)
const query = useQuery(() => ({
queryKey: ['todos', page()],
queryFn: () => fetchTodos(page()),
placeholderData: (previousData) => previousData, // Keep showing old data
}))
return (
<div style={{ opacity: query.isPlaceholderData ? 0.5 : 1 }}>
<For each={query.data?.items}>
{(item) => <Item item={item} />}
</For>
<button
onClick={() => setPage(p => p + 1)}
disabled={query.isPlaceholderData || !query.data?.hasMore}
>
Next
</button>
</div>
)
}
import { useSuspenseQuery } from '@tanstack/solid-query'
import { Suspense, For } from 'solid-js'
import { ErrorBoundary } from 'solid-js'
// Component will suspend until data is loaded
function TodoList() {
const query = useSuspenseQuery(() => ({
queryKey: ['todos'],
queryFn: fetchTodos,
}))
// data is guaranteed to be defined here
return (
<ul>
<For each={query.data}>
{(todo) => <li>{todo.title}</li>}
</For>
</ul>
)
}
// Wrap with Suspense boundary
function App() {
return (
<ErrorBoundary fallback={(err) => <Error error={err} />}>
<Suspense fallback={<Loading />}>
<TodoList />
</Suspense>
</ErrorBoundary>
)
}
// Multiple suspense queries (fetch in parallel)
function Dashboard() {
const [users, projects] = useSuspenseQueries(() => ({
queries: [
{ queryKey: ['users'], queryFn: fetchUsers },
{ queryKey: ['projects'], queryFn: fetchProjects },
],
}))
}
const queryClient = useQueryClient()
// Prefetch on hover
function TodoLink({ todoId }) {
const prefetch = () => {
queryClient.prefetchQuery({
queryKey: ['todo', todoId],
queryFn: () => fetchTodo(todoId),
staleTime: 5000, // Only prefetch if data older than 5s
})
}
return (
<a href={`/todos/${todoId}`} onMouseEnter={prefetch}>
Todo {todoId}
</a>
)
}
// Prefetch in route loader (TanStack Router integration)
export const Route = createFileRoute('/todos/$todoId')({
loader: ({ context: { queryClient }, params: { todoId } }) =>
queryClient.ensureQueryData(todoQueryOptions(todoId)),
})
// Prefetch infinite queries
queryClient.prefetchInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
pages: 3, // Prefetch first 3 pages
})
// Server component or loader
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/solid-query'
async function getServerSideProps() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
return {
props: {
dehydratedState: dehydrate(queryClient),
},
}
}
function Page({ dehydratedState }) {
return (
<HydrationBoundary state={dehydratedState}>
<Todos />
</HydrationBoundary>
)
}
import { dehydrate, HydrationBoundary } from '@tanstack/solid-query'
import { makeQueryClient } from './query-client'
export default async function Page() {
const queryClient = makeQueryClient()
// Prefetch on server
await queryClient.prefetchQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<TodoList />
</HydrationBoundary>
)
}
const queryClient = useQueryClient()
// Get cached data
queryClient.getQueryData(['todos'])
// Set cached data
queryClient.setQueryData(['todos'], updatedTodos)
queryClient.setQueryData(['todos'], (old) => [...old, newTodo])
// Get query state
queryClient.getQueryState(['todos'])
// Check if fetching
queryClient.isFetching({ queryKey: ['todos'] })
queryClient.isMutating()
// Cancel queries
queryClient.cancelQueries({ queryKey: ['todos'] })
// Invalidate (marks stale, refetches active)
queryClient.invalidateQueries({ queryKey: ['todos'] })
// Refetch (force refetch even if fresh)
queryClient.refetchQueries({ queryKey: ['todos'] })
// Remove from cache
queryClient.removeQueries({ queryKey: ['todos'] })
// Reset to initial state
queryClient.resetQueries({ queryKey: ['todos'] })
// Clear entire cache
queryClient.clear()
// Prefetch
queryClient.prefetchQuery({ queryKey: ['todos'], queryFn: fetchTodos })
queryClient.ensureQueryData({ queryKey: ['todos'], queryFn: fetchTodos })
// Get/set defaults
queryClient.setQueryDefaults(['todos'], { staleTime: 10000 })
queryClient.getQueryDefaults(['todos'])
queryClient.setMutationDefaults(['addTodo'], { mutationFn: addTodo })
import { renderHook, waitFor } from '@testing-library/solid'
import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false, // Don't retry in tests
gcTime: Infinity, // Prevent garbage collection during tests
},
},
})
return (props) => (
<QueryClientProvider client={queryClient}>
{props.children}
</QueryClientProvider>
)
}
test('fetches todos', async () => {
const { result } = renderHook(() => useQuery(() => ({
queryKey: ['todos'],
queryFn: fetchTodos,
})), { wrapper: createWrapper })
await waitFor(() => expect(result().isSuccess).toBe(true))
expect(result().data).toEqual(expectedTodos)
})
// Mock with setQueryData for component tests
test('renders todos', () => {
const queryClient = new QueryClient()
queryClient.setQueryData(['todos'], mockTodos)
render(
<QueryClientProvider client={queryClient}>
<TodoList />
</QueryClientProvider>
)
expect(screen.getByText('Todo 1')).toBeInTheDocument()
})
interface Todo {
id: number
title: string
completed: boolean
}
// Type is inferred from queryFn return type
const query = useQuery(() => ({
queryKey: ['todos'],
queryFn: async (): Promise<Todo[]> => {
const res = await fetch('/api/todos')
return res.json()
},
}))
// query.data: Todo[] | undefined
// With select
const query = useQuery(() => ({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (data): string[] => data.map(t => t.title),
}))
// query.data: string[] | undefined
// Default error type is Error
const query = useQuery<Todo[], Error>(() => ({
queryKey: ['todos'],
queryFn: fetchTodos,
}))
// Or register globally
declare module '@tanstack/solid-query' {
interface Register {
defaultError: AxiosError
}
}
import { queryOptions, infiniteQueryOptions } from '@tanstack/solid-query'
export const todosOptions = queryOptions({
queryKey: ['todos'] as const,
queryFn: fetchTodos,
staleTime: 5000,
})
export const todoOptions = (id: number) =>
queryOptions({
queryKey: ['todos', id] as const,
queryFn: () => fetchTodo(id),
enabled: !!id,
})
// Full type inference everywhere
const query = useQuery(todosOptions)
await queryClient.ensureQueryData(todosOptions)
queryClient.invalidateQueries({ queryKey: todosOptions.queryKey })
Solid Query primitives take functions that return options, not plain objects:
// ❌ react version
useQuery({
queryKey: ['todos', todo],
queryFn: fetchTodos,
})
// ✅ solid version
useQuery(() => ({
queryKey: ['todos', todo],
queryFn: fetchTodos,
}))
Solid Query returns a store, not plain objects:
// ❌ React pattern - does NOT work in Solid
const { isPending, data } = useQuery({ ... })
// ✅ Solid pattern - access properties in reactive context
const query = useQuery(() => ({ ... }))
query.isPending // Access directly
query.data
const [enabled, setEnabled] = createSignal(false)
// ✅ passing a signal directly is safe
const query = useQuery(() => ({
queryKey: ['todos'],
queryFn: fetchTodos,
enabled: enabled(), // Signal value is tracked automatically
}))
Use SolidJS native ErrorBoundary for error handling:
import { ErrorBoundary } from 'solid-js'
function App() {
return (
<ErrorBoundary fallback={(err) => <ErrorDisplay error={err} />}>
<Todos />
</ErrorBoundary>
)
}
useQuery/useMutation - this makes options reactivequeryOptions helper for type-safe, reusable query configurationsstaleTime - 0 means always refetch on mount (default), increase for less dynamic dataplaceholderData (not initialData) for keeping previous page data during paginationuseSuspenseQuery when using Suspense boundaries for cleaner component codeenabled as a function for dependent queries, not conditional hook callsonMutate before optimistic updates to prevent race conditionsensureQueryData in route loaders instead of prefetchQuery for immediate accessretry: false in tests to avoid timeout issuesselect for derived data instead of transforming in the componentgcTime: Infinity in tests to prevent cache cleanup during assertionsuseQuery (will not be reactive)initialData when you mean placeholderData (initialData counts as "fresh" data)initialPageParam for infinite queries (required in v5)staleTime higher than gcTime (data gets garbage collected while "fresh")QueryClientProviderQueryClient instance across tests (shared state)invalidateQueries in mutation callbacks when order matters(End of file - total 685 lines)
tools
<!--VITE PLUS START--> # Using Vite+, the Unified Toolchain for the Web This project is using Vite+, a unified toolchain built on top of Vite, Rolldown, Vitest, tsdown, Oxlint, Oxfmt, and Vite Task. Vite+ wraps runtime management, package management, and frontend tooling in a single global CLI called `vp`. Vite+ is distinct from Vite, but it invokes Vite through `vp dev` and `vp build`. ## Vite+ Workflow `vp` is a global binary that handles the full development lifecycle. Run `vp help` to pr
development
Guide for building performant data tables. Uses tanstack-table for table logic (sorting, filtering, pagination) and tanstack-virtual for rendering large datasets efficiently.
development
Expert guidance for building observable, expressive, and fault-tolerant TypeScript applications using the effect-ts/effect ecosystem. Covers Effect<A, E, R> type, error management, dependency injection via Layers, observability (logging, metrics, tracing), concurrency with Fibers, retry/scheduling, Schema validation, Streams, and Sinks.
tools
Complete E2E (end-to-end) and integration testing skill for TypeScript/NestJS projects using Jest, real infrastructure via Docker, and GWT pattern. ALWAYS use this skill when user needs to: **SETUP** - Initialize or configure E2E testing infrastructure: - Set up E2E testing for a new project - Configure docker-compose for testing (Kafka, PostgreSQL, MongoDB, Redis) - Create jest-e2e.config.ts or E2E Jest configuration - Set up test helpers for database, Kafka, or Redis - Configure .env.e2e environment variables - Create test/e2e directory structure **WRITE** - Create or add E2E/integration tests: - Write, create, add, or generate e2e tests or integration tests - Test API endpoints, workflows, or complete features end-to-end - Test with real databases, message brokers, or external services - Test Kafka consumers/producers, event-driven workflows - Working on any file ending in .e2e-spec.ts or in test/e2e/ directory - Use GWT (Given-When-Then) pattern for tests **REVIEW** - Audit or evaluate E2E tests: - Review existing E2E tests for quality - Check test isolation and cleanup patterns - Audit GWT pattern compliance - Evaluate assertion quality and specificity - Check for anti-patterns (multiple WHEN actions, conditional assertions) **RUN** - Execute or analyze E2E test results: - Run E2E tests - Start/stop Docker infrastructure for testing - Analyze E2E test results - Verify Docker services are healthy - Interpret test output and failures **DEBUG** - Fix failing or flaky E2E tests: - Fix failing E2E tests - Debug flaky tests or test isolation issues - Troubleshoot connection errors (database, Kafka, Redis) - Fix timeout issues or async operation failures - Diagnose race conditions or state leakage - Debug Kafka message consumption issues **OPTIMIZE** - Improve E2E test performance: - Speed up slow E2E tests - Optimize Docker infrastructure startup - Replace fixed waits with smart polling - Reduce beforeEach cleanup time - Improve test parallelization where safe Keywords: e2e, end-to-end, integration test, e2e-spec.ts, test/e2e, Jest, supertest, NestJS, Kafka, Redpanda, PostgreSQL, MongoDB, Redis, docker-compose, GWT pattern, Given-When-Then, real infrastructure, test isolation, flaky test, MSW, nock, waitForMessages, fix e2e, debug e2e, run e2e, review e2e, optimize e2e, setup e2e