Skill: tanstack-query
Scope
-
Applies to: TanStack Query v5+ for async operations, data fetching, caching, mutations, infinite queries, optimistic updates
-
Does NOT cover: URL state management (use nuqs), grouped synchronous state (use ahooks.useSetState), localStorage persistence (use ahooks.useLocalStorageState)
Assumptions
-
TanStack Query v5+
-
React 18+ with hooks support
-
TypeScript v5+ (for type inference)
-
@lukemorales/query-key-factory for query key management
-
QueryClientProvider configured at app root
Principles
-
Use TanStack Query for ANY async operation that benefits from caching or state management
-
Query key factory pattern for centralized, type-safe query keys
-
Never manually manage isLoading , error , or isError states (provided by hooks)
-
queryFn can be any Promise-returning function (not just HTTP calls)
-
All TanStack features work identically regardless of data source: caching, deduping, background refetching, stale-while-revalidate
Constraints
MUST
-
Use query key factory (@lukemorales/query-key-factory ) for all query keys
-
Use TanStack Query hooks for async operations (never manually manage loading/error states)
-
Use namespace invalidation: queryClient.invalidateQueries({ queryKey: users._def })
SHOULD
-
Use TanStack Query for any async operation that benefits from caching (HTTP, localStorage reads, local computations, file operations)
-
Extract query logic into custom hooks for reusability
-
Combine multiple queries into cohesive hooks
-
Use TypeScript generics for type-safe queries: useQuery<ResponseType>(...)
-
Configure QueryClient defaults (retry, staleTime) at provider level
AVOID
-
Manually constructing query keys: useQuery({ queryKey: ['users', id] })
-
Hardcoding query keys in invalidations: queryClient.invalidateQueries({ queryKey: ['users'] })
-
Manually managing loading/error states (use hook-provided states)
-
Using for URL-shareable state (use nuqs instead)
-
Using for grouped synchronous state (use ahooks.useSetState instead)
-
Using for one-off promises without caching needs (use plain async/await in event handlers)
Interactions
-
Complements nuqs for URL state management (queries can depend on URL params)
-
Complements ahooks for synchronous state (useSetState for form state, useLocalStorageState for persistence)
-
Works with generated API clients from OpenAPI specs (@repo/react)
-
Part of state management decision tree (see React rules)
Patterns
Query Key Factory Pattern
Centralized, type-safe query keys:
import { createQueryKeys } from '@lukemorales/query-key-factory'
export const users = createQueryKeys('users', { detail: (id: string) => ({ queryKey: [id], queryFn: () => fetchUser(id), }), list: (filters?: Filters) => ({ queryKey: [filters], queryFn: () => fetchUsers(filters), }), })
// Usage import { useQuery } from '@tanstack/react-query' import { users } from '@/queries/users'
const { data, isLoading, error } = useQuery(users.detail(userId))
File Organization: src/queries/users.ts
- Query key factory for users, src/queries/articles.ts
- Query key factory for articles, src/queries/index.ts
- Re-export all query keys
Versatile queryFn Pattern
TanStack Query accepts ANY async queryFn —not just HTTP calls:
// Server data fetching (traditional) const { data } = useQuery({ queryKey: ['users', userId], queryFn: () => fetchUser(userId), })
// Local computation (no network) const { data } = useQuery({ queryKey: ['fibonacci', n], queryFn: () => computeFibonacci(n), })
// localStorage read const { data: settings } = useQuery({ queryKey: ['user-settings'], queryFn: () => Promise.resolve( JSON.parse(localStorage.getItem('settings') || '{}') ), })
// File operation const { data } = useQuery({ queryKey: ['file-content', path], queryFn: () => readFile(path), })
When to use: Any async operation that benefits from automatic caching, stale management, or repeated execution
When NOT to use: One-off promises without caching needs (use plain async/await in event handlers)
Mutation Pattern
Mutations with automatic invalidation:
import { useMutation, useQueryClient } from '@tanstack/react-query' import { users } from '@/queries/users'
function useCreateUser() { const queryClient = useQueryClient()
return useMutation({ mutationFn: async (data: CreateUserInput) => { const response = await createUser(data) return response }, onSuccess: () => { // Namespace invalidation (recommended) queryClient.invalidateQueries({ queryKey: users._def }) }, }) }
// Usage const createUser = useCreateUser() createUser.mutate({ name: 'John', email: 'john@example.com' })
Infinite Query Pattern
Pagination with infinite scroll:
import { useInfiniteQuery } from '@tanstack/react-query' import { users } from '@/queries/users'
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfiniteQuery({ queryKey: ['users', 'infinite'], queryFn: ({ pageParam = 0 }) => fetchUsers({ page: pageParam }), getNextPageParam: (lastPage, pages) => lastPage.nextPage, initialPageParam: 0, })
QueryClient Configuration
Configure defaults at provider level:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { useState } from 'react'
function Providers({ children }: { children: ReactNode }) { const [queryClient] = useState(() => new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, // 1 minute retry: 1, refetchOnWindowFocus: false, }, }, }))
return ( <QueryClientProvider client={queryClient}> {children} </QueryClientProvider> ) }
Generated API Client Integration
Use generated hooks from OpenAPI specs:
import { useHealthCheck } from '@repo/react'
function HealthStatus() { const { data, isLoading, error } = useHealthCheck()
if (isLoading) return <div>Loading...</div> if (error) return <div>Error: {error.message}</div>
return <div>Status: {data.status}</div> }
Custom Hook Pattern
Extract query logic into reusable hooks:
// hooks/useUser.ts import { useQuery } from '@tanstack/react-query' import { users } from '@/queries/users'
export function useUser(userId: string) { return useQuery(users.detail(userId)) }
// Component usage function UserProfile({ userId }: { userId: string }) { const { data: user, isLoading, error } = useUser(userId) // ... }
State Management Decision Tree
-
URL-shareable state → Use nuqs (filters, search, tabs, pagination)
-
Grouped state not in URL → Use ahooks.useSetState (form state, game engine, ephemeral UI)
-
Async operations → Use TanStack Query (data fetching, mutations, caching)
-
localStorage persistence → Use ahooks.useLocalStorageState (preferences, settings)
-
Simple independent state → Use useState (rare, prefer other options)
Use TanStack Query for: Server data fetching, mutations, optimistic updates, background refetching, caching, any async operation that benefits from state management
Don't use TanStack Query for: URL-shareable state (use nuqs), grouped synchronous state (use ahooks.useSetState), one-off promises without caching needs
References
-
TanStack Query documentation - Official documentation
-
Query Key Factory - Query key factory library
-
React rules - State management decision tree and patterns
-
ahooks - Utility hooks for synchronous state
-
OpenAPI Integration - Generated client patterns