plugins/dev/skills/frontend/tanstack-query/SKILL.md
Provides TanStack Query v5 patterns for query keys, mutations, optimistic updates, and MSW testing. Use when working on server state, data fetching, cache invalidation, or useQuery/useMutation hooks.
npx skillsauth add madappgang/magus 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 v5 (October 2023) is the async state manager for this project. It requires React 18+, features first-class Suspense support, improved TypeScript inference, and a 20% smaller bundle. This section covers production-ready patterns based on official documentation and community best practices.
Key updates you need to know:
Single Object Signature: All hooks now accept one configuration object:
// ✅ v5 - single object
useQuery({ queryKey, queryFn, ...options })
// ❌ v4 - multiple overloads (deprecated)
useQuery(queryKey, queryFn, options)
Renamed Options:
cacheTime → gcTime (garbage collection time)keepPreviousData → placeholderData: keepPreviousDataisLoading now means isPending && isFetchingCallbacks Removed from useQuery:
onSuccess, onError, onSettled removed from useQueryInfinite Queries Require initialPageParam:
initialPageParam (e.g., 0 or null)First-Class Suspense:
useSuspenseQuery, useSuspenseInfiniteQueryMigration: Use the official codemod for automatic migration: npx @tanstack/query-codemods v5/replace-import-specifier
Query v5 ships with production-ready defaults:
{
staleTime: 0, // Data instantly stale (refetch on mount)
gcTime: 5 * 60_000, // Keep unused cache for 5 minutes
retry: 3, // 3 retries with exponential backoff
refetchOnWindowFocus: true,// Refetch when user returns to tab
refetchOnReconnect: true, // Refetch when network reconnects
}
Philosophy: React Query is an async state manager, not a data fetcher. You provide the Promise; Query manages caching, background updates, and synchronization.
// src/app/providers.tsx
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query'
import { toast } from './toast' // Your notification system
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 0, // Adjust per-query
gcTime: 5 * 60_000, // 5 minutes (v5: formerly cacheTime)
retry: (failureCount, error) => {
// Don't retry on 401 (authentication errors)
if (error?.response?.status === 401) return false
return failureCount < 3
},
},
},
queryCache: new QueryCache({
onError: (error, query) => {
// Only show toast for background errors (when data exists)
if (query.state.data !== undefined) {
toast.error(`Something went wrong: ${error.message}`)
}
},
}),
})
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
DevTools Setup (auto-excluded in production):
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
When using TanStack Query with SSR (Next.js, Remix, TanStack Start), configure server-specific defaults:
// Server-side QueryClient configuration
export function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
// Server: Don't retry on server (fail fast)
retry: typeof window === 'undefined' ? 0 : 3,
// Server: Data is always fresh when rendered
staleTime: 60_000, // 1 minute
},
},
})
}
Server vs Client Defaults:
| Option | Client Default | Server Recommended | Why |
|--------|---------------|-------------------|-----|
| retry | 3 | 0 | Server should fail fast, not retry loops |
| staleTime | 0 | 60_000+ | Server-rendered data is fresh |
| gcTime | 5 min | Infinity | No garbage collection needed on server |
| refetchOnWindowFocus | true | false | No window on server |
| refetchOnReconnect | true | false | No reconnect on server |
Important: In SPA-only apps (TanStack Router + Vite), you don't need these server defaults. They're only relevant for SSR frameworks.
For Next.js App Router, @tanstack/react-query-next-experimental enables streaming:
pnpm add @tanstack/react-query-next-experimental
Setup:
// app/providers.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental'
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { staleTime: 60_000 },
},
})
}
let browserQueryClient: QueryClient | undefined
function getQueryClient() {
if (typeof window === 'undefined') {
return makeQueryClient() // Server: always new
}
return (browserQueryClient ??= makeQueryClient()) // Browser: singleton
}
export function Providers({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient()
return (
<QueryClientProvider client={queryClient}>
<ReactQueryStreamedHydration>{children}</ReactQueryStreamedHydration>
</QueryClientProvider>
)
}
Usage in Client Components:
'use client'
import { useSuspenseQuery } from '@tanstack/react-query'
export function UserProfile({ userId }: { userId: string }) {
// No prefetch needed! Data streams from server
const { data: user } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
return <div>{user.name}</div>
}
Benefits:
Limitations:
When you have React Server Components (RSC), how does TanStack Query fit?
Think of Server Components as another framework loader (like route loaders):
| Feature | Server Components | TanStack Query | |---------|-------------------|----------------| | Initial data fetch | Yes (server) | Yes (client prefetch) | | Client mutations | No | Yes | | Background refetch | No | Yes | | Optimistic updates | No | Yes | | Real-time updates | No | Yes | | Cache management | No | Yes |
Even in RSC-heavy apps, Query remains essential for:
Client-Side Mutations
// Server Component fetches, Client handles mutations
export default async function PostPage({ params }) {
const post = await fetchPost(params.id) // Server fetch
return <PostWithComments post={post} /> // Client mutations
}
'use client'
function PostWithComments({ post }) {
const addComment = useMutation({ ... }) // Still need Query!
// ...
}
Background Refetching After Initial Load
// Initial: Server Component renders with fresh data
// After: Query keeps data fresh on client
Optimistic Updates
// Can't do optimistic updates with Server Components alone
const likeMutation = useMutation({
mutationFn: likePost,
onMutate: async () => {
// Optimistic update - only possible with Query
},
})
Real-Time Updates
// WebSocket data, polling, etc. - client-only
useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
refetchInterval: 30_000, // Real-time polling
})
// Server Component: Initial fetch
export default async function DashboardPage() {
const initialData = await fetchDashboard()
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<DashboardClient initialData={initialData} />
</HydrationBoundary>
)
}
// Client Component: Mutations + real-time
'use client'
function DashboardClient({ initialData }) {
// Query hydrates from server data, then manages client state
const { data } = useQuery({
queryKey: ['dashboard'],
queryFn: fetchDashboard,
initialData,
})
const updateWidget = useMutation({ ... })
// ...
}
For SPA-only apps (TanStack Router + Vite): Server Components don't apply. Use TanStack Query as your primary data layer with route loaders for prefetching.
When using React 19 Actions alongside Query, keep responsibilities clear:
// Query: Fetching and caching
const { data: posts } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
// Action: Form submission with built-in validation
async function createPostAction(formData: FormData) {
'use server'
const result = await createPost(formData)
return result
}
// After action succeeds, invalidate Query cache
const [state, formAction] = useActionState(async (prev, formData) => {
const result = await createPostAction(formData)
if (result.success) {
queryClient.invalidateQueries({ queryKey: ['posts'] })
}
return result
}, { success: false })
| Use Case | Recommendation |
|----------|----------------|
| Data fetching | Query (useQuery) |
| List caching | Query |
| Form submission | Action (useActionState) + Query invalidation |
| Button click mutation | Query (useMutation) |
| Optimistic update with rollback | Query (useMutation) |
| Server-side validation | Action |
| Complex multi-step mutations | Query (useMutation) |
Recommended pattern: Group queries with related features, not by file type.
src/features/
├── Todos/
│ ├── index.tsx # Feature entry point
│ ├── queries.ts # All React Query logic (keys, functions, hooks)
│ ├── types.ts # TypeScript types
│ └── components/ # Feature-specific components
Export only custom hooks from query files. Keep query functions and keys private:
// features/todos/queries.ts
// 1. Query Key Factory (hierarchical structure)
const todoKeys = {
all: ['todos'] as const,
lists: () => [...todoKeys.all, 'list'] as const,
list: (filters: string) => [...todoKeys.lists(), { filters }] as const,
details: () => [...todoKeys.all, 'detail'] as const,
detail: (id: number) => [...todoKeys.details(), id] as const,
}
// 2. Query Function (private)
const fetchTodos = async (filters: string): Promise<Todo[]> => {
const response = await axios.get('/api/todos', { params: { filters } })
return response.data
}
// 3. Custom Hook (public API)
export const useTodosQuery = (filters: string) => {
return useQuery({
queryKey: todoKeys.list(filters),
queryFn: () => fetchTodos(filters),
staleTime: 30_000, // Fresh for 30 seconds
})
}
Benefits:
Structure keys hierarchically from generic to specific:
// ✅ Correct hierarchy
['todos'] // Invalidates everything
['todos', 'list'] // Invalidates all lists
['todos', 'list', { filters }] // Invalidates specific list
['todos', 'detail', 1] // Invalidates specific detail
// ❌ Wrong - flat structure
['todos-list-active'] // Can't partially invalidate
Critical rule: Query keys must include ALL variables used in queryFn. Treat query keys like dependency arrays:
// ✅ Correct - includes all variables
const { data } = useQuery({
queryKey: ['todos', filters, sortBy],
queryFn: () => fetchTodos(filters, sortBy),
})
// ❌ Wrong - missing variables
const { data } = useQuery({
queryKey: ['todos'],
queryFn: () => fetchTodos(filters, sortBy), // filters/sortBy not in key!
})
Type consistency matters: ['todos', '1'] and ['todos', 1] are different keys. Be consistent with types.
The modern pattern for maximum type safety across your codebase:
import { queryOptions } from '@tanstack/react-query'
function todoOptions(id: number) {
return queryOptions({
queryKey: ['todos', id],
queryFn: () => fetchTodo(id),
staleTime: 5000,
})
}
// ✅ Use everywhere with full type safety
useQuery(todoOptions(1))
queryClient.prefetchQuery(todoOptions(5))
queryClient.setQueryData(todoOptions(42).queryKey, newTodo)
queryClient.getQueryData(todoOptions(42).queryKey) // Fully typed!
Benefits:
Choose the right approach based on your use case:
1. Transform in queryFn - Simple cases where cache should store transformed data:
const fetchTodos = async (): Promise<Todo[]> => {
const response = await axios.get('/api/todos')
return response.data.map(todo => ({
...todo,
name: todo.name.toUpperCase()
}))
}
2. Transform with select option (RECOMMENDED) - Enables partial subscriptions:
// Only re-renders when filtered data changes
export const useTodosQuery = (filters: string) =>
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (data) => data.filter(todo => todo.status === filters),
})
// Only re-renders when count changes
export const useTodosCount = () =>
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (data) => data.length,
})
⚠️ Memoize select functions to prevent running on every render:
// ✅ Stable reference
const transformTodos = (data: Todo[]) => expensiveTransform(data)
const query = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: transformTodos, // Stable function reference
})
// ❌ Runs on every render
const query = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (data) => expensiveTransform(data), // New function every render
})
Let TypeScript infer types from queryFn rather than specifying generics:
// ✅ Recommended - inference
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos, // Returns Promise<Todo[]>
})
// data is Todo[] | undefined
// ❌ Unnecessary - explicit generics
const { data } = useQuery<Todo[]>({
queryKey: ['todos'],
queryFn: fetchTodos,
})
Discriminated unions automatically narrow types:
const { data, isSuccess, isError, error } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
if (isSuccess) {
// data is Todo[] (never undefined)
}
if (isError) {
// error is defined
}
Use queryOptions helper for maximum type safety across imperative methods.
Always create custom hooks even for single queries:
// ✅ Recommended - custom hook with encapsulation
export function usePost(
id: number,
options?: Omit<UseQueryOptions<Post>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: ['posts', id],
queryFn: () => getPost(id),
...options,
})
}
// Usage: allows callers to override any option except key/fn
const { data } = usePost(42, { staleTime: 10_000 })
Benefits:
Layer 1: Component-Level - Specific user feedback:
function TaskList() {
const { data, error, isError, isLoading } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
if (isLoading) return <Spinner />
if (isError) return <ErrorAlert>{error.message}</ErrorAlert>
return <ul>{data.map(todo => <TodoItem key={todo.id} {...todo} />)}</ul>
}
Layer 2: Global Error Handling - Background errors via QueryCache:
// Already configured in client setup above
queryCache: new QueryCache({
onError: (error, query) => {
if (query.state.data !== undefined) {
toast.error(`Background error: ${error.message}`)
}
},
})
Layer 3: Error Boundaries - Catch render errors:
import { QueryErrorResetBoundary } from '@tanstack/react-query'
import { ErrorBoundary } from 'react-error-boundary'
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ error, resetErrorBoundary }) => (
<div>
<p>Error: {error.message}</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
)}
>
<TaskList />
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
First-class Suspense support in v5 with dedicated hooks:
import { useSuspenseQuery } from '@tanstack/react-query'
function TaskList() {
// data is NEVER undefined (type-safe)
const { data } = useSuspenseQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
return <ul>{data.map(todo => <TodoItem key={todo.id} {...todo} />)}</ul>
}
// Wrap with Suspense boundary
function App() {
return (
<Suspense fallback={<Spinner />}>
<TaskList />
</Suspense>
)
}
Benefits:
Basic mutation with cache invalidation:
export function useCreateTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (newTodo: CreateTodoDTO) =>
api.post('/todos', newTodo).then(res => res.data),
onSuccess: (data) => {
// Set detail query immediately
queryClient.setQueryData(['todos', data.id], data)
// Invalidate list queries
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] })
},
})
}
Simple optimistic updates using variables:
const addTodoMutation = useMutation({
mutationFn: (newTodo: string) => axios.post('/api/todos', { text: newTodo }),
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
})
const { isPending, variables, mutate } = addTodoMutation
return (
<ul>
{todoQuery.data?.map(todo => <li key={todo.id}>{todo.text}</li>)}
{isPending && <li style={{ opacity: 0.5 }}>{variables}</li>}
</ul>
)
Advanced optimistic updates with rollback:
useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
// Cancel outgoing queries (prevent race conditions)
await queryClient.cancelQueries({ queryKey: ['todos'] })
// Snapshot current data
const previousTodos = queryClient.getQueryData(['todos'])
// Optimistically update cache
queryClient.setQueryData(['todos'], (old: Todo[]) =>
old?.map(todo => todo.id === newTodo.id ? newTodo : todo)
)
// Return context for rollback
return { previousTodos }
},
onError: (err, newTodo, context) => {
// Rollback on error
queryClient.setQueryData(['todos'], context?.previousTodos)
toast.error('Update failed. Changes reverted.')
},
onSettled: () => {
// Always refetch to ensure consistency
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
Key principles:
onMutate to prevent race conditionsonSettled for eventual consistencyHandle token refresh at HTTP client level (not React Query):
// src/lib/api-client.ts
import axios from 'axios'
import createAuthRefreshInterceptor from 'axios-auth-refresh'
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_URL,
})
// Add token to requests
apiClient.interceptors.request.use((config) => {
const token = getAccessToken()
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
// Refresh token on 401
const refreshAuth = async (failedRequest: any) => {
try {
const newToken = await fetchNewToken()
failedRequest.response.config.headers.Authorization = `Bearer ${newToken}`
setAccessToken(newToken)
return Promise.resolve()
} catch {
removeAccessToken()
window.location.href = '/login'
return Promise.reject()
}
}
createAuthRefreshInterceptor(apiClient, refreshAuth, {
statusCodes: [401],
pauseInstanceWhileRefreshing: true,
})
Protected queries use the enabled option:
const useTodos = () => {
const { user } = useUser() // Get current user from auth context
return useQuery({
queryKey: ['todos', user?.id],
queryFn: () => fetchTodos(user.id),
enabled: !!user, // Only execute when user exists
})
}
On logout: Clear the entire cache with queryClient.clear() (not invalidateQueries() which triggers refetches):
const logout = () => {
removeAccessToken()
queryClient.clear() // Clear all cached data
navigate('/login')
}
Prefetching - Eliminate loading states:
// Hover prefetching
function ShowDetailsButton() {
const queryClient = useQueryClient()
const prefetch = () => {
queryClient.prefetchQuery({
queryKey: ['details'],
queryFn: getDetailsData,
staleTime: 60_000, // Consider fresh for 1 minute
})
}
return (
<button onMouseEnter={prefetch} onClick={showDetails}>
Show Details
</button>
)
}
// Route-level prefetching (see Router × Query Integration section)
Infinite Queries - Infinite scrolling/pagination:
function Projects() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery({
queryKey: ['projects'],
queryFn: ({ pageParam }) => fetchProjects(pageParam),
initialPageParam: 0, // Required in v5
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
if (isLoading) return <Spinner />
return (
<>
{data.pages.map((page, i) => (
<React.Fragment key={i}>
{page.data.map(project => (
<ProjectCard key={project.id} {...project} />
))}
</React.Fragment>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
</>
)
}
Offset-Based Pagination with placeholderData:
import { keepPreviousData } from '@tanstack/react-query'
function Posts() {
const [page, setPage] = useState(0)
const { data, isPending, isPlaceholderData } = useQuery({
queryKey: ['posts', page],
queryFn: () => fetchPosts(page),
placeholderData: keepPreviousData, // Show previous data while fetching
})
return (
<>
{data.posts.map(post => <PostCard key={post.id} {...post} />)}
<button
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
>
Previous
</button>
<button
onClick={() => setPage(p => p + 1)}
disabled={isPlaceholderData || !data.hasMore}
>
Next
</button>
</>
)
}
Dependent Queries - Sequential data fetching:
function UserProjects({ email }: { email: string }) {
// First query
const { data: user } = useQuery({
queryKey: ['user', email],
queryFn: () => getUserByEmail(email),
})
// Second query waits for first
const { data: projects } = useQuery({
queryKey: ['projects', user?.id],
queryFn: () => getProjectsByUser(user.id),
enabled: !!user?.id, // Only runs when user.id exists
})
return <div>{/* render projects */}</div>
}
staleTime is your primary control - adjust this, not gcTime:
// Real-time data (default)
staleTime: 0 // Always considered stale, refetch on mount
// User profiles (changes infrequently)
staleTime: 1000 * 60 * 2 // Fresh for 2 minutes
// Static reference data
staleTime: 1000 * 60 * 10 // Fresh for 10 minutes
Query deduplication happens automatically - multiple components mounting with identical query keys result in a single network request, but all components receive data.
Prevent request waterfalls:
// ❌ Waterfall - each query waits for previous
function Dashboard() {
const { data: user } = useQuery(userQuery)
const { data: posts } = useQuery(postsQuery(user?.id))
const { data: stats } = useQuery(statsQuery(user?.id))
}
// ✅ Parallel - all queries start simultaneously
function Dashboard() {
const { data: user } = useQuery(userQuery)
const { data: posts } = useQuery({
...postsQuery(user?.id),
enabled: !!user?.id,
})
const { data: stats } = useQuery({
...statsQuery(user?.id),
enabled: !!user?.id,
})
}
// ✅ Best - prefetch in route loader (see Router × Query Integration)
Never copy server state to local state - this opts out of background updates:
// ❌ Wrong - copies to state, loses reactivity
const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
const [todos, setTodos] = useState(data)
// ✅ Correct - use query data directly
const { data: todos } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
MSW is the recommended approach - mock the network layer:
// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/todos', () => {
return HttpResponse.json([
{ id: 1, text: 'Test todo', completed: false },
])
}),
http.post('/api/todos', async ({ request }) => {
const newTodo = await request.json()
return HttpResponse.json({ id: 2, ...newTodo })
}),
]
// src/test/setup.ts
import { setupServer } from 'msw/node'
import { handlers } from './mocks/handlers'
export const server = setupServer(...handlers)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
Create test wrappers with proper QueryClient:
// src/test/utils.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render } from '@testing-library/react'
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false, // Prevent retries in tests
gcTime: Infinity,
},
},
})
}
export function renderWithClient(ui: React.ReactElement) {
const testQueryClient = createTestQueryClient()
return render(
<QueryClientProvider client={testQueryClient}>
{ui}
</QueryClientProvider>
)
}
Test queries:
import { renderWithClient } from '@/test/utils'
import { screen } from '@testing-library/react'
test('displays todos', async () => {
renderWithClient(<TaskList />)
// Wait for data to load
expect(await screen.findByText('Test todo')).toBeInTheDocument()
})
test('shows error state', async () => {
// Override handler for this test
server.use(
http.get('/api/todos', () => {
return HttpResponse.json(
{ message: 'Failed to fetch' },
{ status: 500 }
)
})
)
renderWithClient(<TaskList />)
expect(await screen.findByText(/failed/i)).toBeInTheDocument()
})
Critical testing principles:
retry: false to prevent timeoutsfindBy*) for data that loads❌ Don't store query data in Redux/Context:
❌ Don't call refetch() with different parameters:
// ❌ Wrong - breaks declarative pattern
const { data, refetch } = useQuery({
queryKey: ['todos'],
queryFn: () => fetchTodos(filters),
})
// Later: refetch with different filters??? Won't work!
// ✅ Correct - include params in key
const [filters, setFilters] = useState('all')
const { data } = useQuery({
queryKey: ['todos', filters],
queryFn: () => fetchTodos(filters),
})
// Changing filters automatically refetches
❌ Don't use queries for local state:
❌ Don't create QueryClient inside components:
// ❌ Wrong - new cache every render
function App() {
const client = new QueryClient()
return <QueryClientProvider client={client}>...</QueryClientProvider>
}
// ✅ Correct - stable instance
const queryClient = new QueryClient()
function App() {
return <QueryClientProvider client={queryClient}>...</QueryClientProvider>
}
❌ Don't ignore loading and error states - always handle both
❌ Don't transform data by copying to state - use select option
❌ Don't mismatch query keys - be consistent with types ('1' vs 1)
staleTime - How long data is considered fresh:
0 (default) - Always stale, refetch on mount/focus30_000 (30s) - Good for user-generated content120_000 (2min) - Good for profile data600_000 (10min) - Good for static reference datagcTime (formerly cacheTime) - How long unused data stays in cache:
300_000 (5min, default) - Good for most casesInfinity - Keep forever (useful with persistence)0 - Immediate garbage collection (not recommended)Relationship: staleTime controls refetch frequency, gcTime controls memory cleanup.
Seamlessly integrate TanStack Router with TanStack Query for optimal SPA performance and instant navigation.
The key pattern: Use route loaders to prefetch queries BEFORE navigation completes.
Benefits:
// src/routes/users/$id.tsx
import { createFileRoute } from '@tanstack/react-router'
import { queryClient } from '@/app/queryClient'
import { usersKeys, fetchUser } from '@/features/users/queries'
export const Route = createFileRoute('/users/$id')({
loader: async ({ params }) => {
const id = params.id
return queryClient.ensureQueryData({
queryKey: usersKeys.detail(id),
queryFn: () => fetchUser(id),
staleTime: 30_000, // Fresh for 30 seconds
})
},
component: UserPage,
})
function UserPage() {
const { id } = Route.useParams()
const { data: user } = useQuery({
queryKey: usersKeys.detail(id),
queryFn: () => fetchUser(id),
})
// Data is already loaded from loader, so this returns instantly
return <div>{user.name}</div>
}
Query Options provide maximum type safety and DRY:
// features/users/queries.ts
import { queryOptions } from '@tanstack/react-query'
export function userQueryOptions(userId: string) {
return queryOptions({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
staleTime: 30_000,
})
}
export function useUser(userId: string) {
return useQuery(userQueryOptions(userId))
}
// src/routes/users/$userId.tsx
import { userQueryOptions } from '@/features/users/queries'
import { queryClient } from '@/app/queryClient'
export const Route = createFileRoute('/users/$userId')({
loader: ({ params }) =>
queryClient.ensureQueryData(userQueryOptions(params.userId)),
component: UserPage,
})
function UserPage() {
const { userId } = Route.useParams()
const { data: user } = useUser(userId)
return <div>{user.name}</div>
}
export const Route = createFileRoute('/dashboard')({
loader: async () => {
// Run in parallel
await Promise.all([
queryClient.ensureQueryData(userQueryOptions()),
queryClient.ensureQueryData(statsQueryOptions()),
queryClient.ensureQueryData(postsQueryOptions()),
])
},
component: Dashboard,
})
function Dashboard() {
const { data: user } = useUser()
const { data: stats } = useStats()
const { data: posts } = usePosts()
// All data pre-loaded, renders instantly
return (
<div>
<UserHeader user={user} />
<StatsPanel stats={stats} />
<PostsList posts={posts} />
</div>
)
}
export const Route = createFileRoute('/users/$userId/posts')({
loader: async ({ params }) => {
// First ensure user data
const user = await queryClient.ensureQueryData(
userQueryOptions(params.userId)
)
// Then fetch user's posts
return queryClient.ensureQueryData(
userPostsQueryOptions(user.id)
)
},
component: UserPostsPage,
})
prefetchQuery - Fire and forget, don't wait:
loader: ({ params }) => {
// Don't await - just start fetching
queryClient.prefetchQuery(userQueryOptions(params.userId))
// Navigation continues immediately
}
ensureQueryData - Wait for data (recommended):
loader: async ({ params }) => {
// Await - navigation waits until data is ready
return await queryClient.ensureQueryData(userQueryOptions(params.userId))
}
fetchQuery - Always fetches fresh:
loader: async ({ params }) => {
// Ignores cache, always fetches
return await queryClient.fetchQuery(userQueryOptions(params.userId))
}
Recommendation: Use ensureQueryData for most cases - respects cache and staleTime.
import { Link } from '@tanstack/react-router'
<Link
to="/users/$userId"
params={{ userId: '123' }}
preload="intent" // Preload on hover/focus
>
View User
</Link>
// src/routes/users/index.tsx
import { z } from 'zod'
const searchSchema = z.object({
page: z.number().default(1),
filter: z.enum(['active', 'all']).default('all'),
})
export const Route = createFileRoute('/users/')({
validateSearch: searchSchema,
loader: ({ search }) => {
return queryClient.ensureQueryData(
usersQueryOptions({ page: search.page, filter: search.filter })
)
},
component: UsersPage,
})
function UsersPage() {
const { page, filter } = Route.useSearch()
const { data: users } = useUsers({ page, filter })
return <UsersList users={users} />
}
testing
A test skill for validation testing. Use when testing skill parsing and validation logic.
tools
--- name: bad-skill description: This skill has invalid YAML in frontmatter allowed-tools: [invalid, array, syntax prerequisites: not-an-array --- # Bad Skill This skill has malformed frontmatter that should fail parsing. The YAML has: - Unclosed array bracket - Wrong type for prerequisites (should be array, not string)
development
Sync model aliases from the curated Firebase database. Fetches default model assignments, short aliases, team compositions, and known model metadata from the claudish API. Run this to get fresh model recommendations.
tools
Release one or more Magus plugins to the distribution repos (magus, magus-alpha, magus-marketing). Handles version inference from git history, marketplace.json updates, tagging, and force-push to lean dist repos. Use whenever the user says "release kanban", "release the dev plugin", "cut a new version of gtd", "bump kanban to 1.7", or hands you a batch like "release kanban and gtd". Also use for multi-plugin releases and for checking what a release would contain before committing.