.claude/skills/tanstack-query/SKILL.md
TanStack Query (React Query) patterns for data fetching in this Next.js application. Covers useQuery, useMutation, optimistic updates, cache invalidation, and anti-patterns. Use this skill when implementing data fetching or state management with server data.
npx skillsauth add NextSpark-js/nextspark 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.
Data fetching patterns and best practices with TanStack Query (React Query).
core/providers/
└── query-provider.tsx # QueryClient configuration
core/hooks/
├── useEntityQuery.ts # Generic entity query hook
├── useEntityMutations.ts # CRUD mutations with optimistic updates
└── useUserProfile.ts # Example simple mutation
// core/providers/query-provider.tsx
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute default
refetchOnWindowFocus: false, // Disabled
},
},
})
)
return (
<QueryClientProvider client={queryClient}>
{children}
{process.env.NEXT_PUBLIC_RQ_DEVTOOLS === 'true' && (
<ReactQueryDevtools initialIsOpen={false} />
)}
</QueryClientProvider>
)
}
Key Configuration:
| Setting | Value | Reason |
|---------|-------|--------|
| staleTime | 60 seconds | Prevents excessive refetching |
| refetchOnWindowFocus | false | Manual control over refetching |
| gcTime | 5 minutes (default) | Cache cleanup |
Query keys should be hierarchical arrays for proper cache management:
// Pattern: ['domain', 'resource', filters, id]
// Entity list with filters
['entity', 'tasks', { page: 1, search: 'test', status: 'active' }]
// Single entity
['entity', 'tasks', 'task-123']
// Single entity with options
['entity', 'tasks', 'task-123', { includeChildren: true }]
// User-specific data
['user-profile']
['user-settings', 'notifications']
// Admin data
['superadmin-users', search, roleFilter, statusFilter, page]
import { useQuery } from '@tanstack/react-query'
function useTaskList(filters: TaskFilters) {
return useQuery({
queryKey: ['entity', 'tasks', filters],
queryFn: async () => {
const params = new URLSearchParams({
page: String(filters.page),
limit: String(filters.limit),
...(filters.status && { status: filters.status }),
...(filters.search && { search: filters.search }),
})
const response = await fetch(`/api/v1/tasks?${params}`)
if (!response.ok) {
throw new Error('Failed to fetch tasks')
}
return response.json()
},
})
}
function useTask(id: string | null) {
return useQuery({
queryKey: ['entity', 'tasks', id],
queryFn: async () => {
const response = await fetch(`/api/v1/tasks/${id}`)
if (!response.ok) throw new Error('Failed to fetch task')
return response.json()
},
enabled: !!id, // Only fetch when id exists
})
}
function useEntityQuery(entityConfig: EntityConfig, filters: Filters) {
const { user } = useAuth()
return useQuery({
queryKey: ['entity', entityConfig.slug, filters],
queryFn: async () => {
const response = await fetch(`/api/v1/${entityConfig.slug}?${params}`)
if (!response.ok) throw new Error('Failed to fetch')
return response.json()
},
enabled: !!user, // Only fetch when authenticated
staleTime: 1000 * 60 * 5, // 5 minutes for entity lists
gcTime: 1000 * 60 * 60, // 1 hour garbage collection
})
}
| Option | Type | Description |
|--------|------|-------------|
| queryKey | unknown[] | Cache key (required) |
| queryFn | () => Promise<T> | Fetch function (required) |
| enabled | boolean | Conditional fetching |
| staleTime | number | Time before data is stale (ms) |
| gcTime | number | Cache retention time (ms) |
| retry | number \| boolean | Retry attempts |
| refetchOnWindowFocus | boolean | Refetch on tab focus |
| refetchInterval | number | Polling interval (ms) |
import { useMutation, useQueryClient } from '@tanstack/react-query'
function useCreateTask() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data: CreateTaskData) => {
const response = await fetch('/api/v1/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) throw new Error('Failed to create task')
return response.json()
},
onSuccess: () => {
// Invalidate all task queries to refetch
queryClient.invalidateQueries({ queryKey: ['entity', 'tasks'] })
},
})
}
// Usage
function CreateTaskForm() {
const createTask = useCreateTask()
const handleSubmit = async (data: CreateTaskData) => {
try {
await createTask.mutateAsync(data)
toast.success('Task created!')
} catch (error) {
toast.error('Failed to create task')
}
}
return (
<form onSubmit={handleSubmit}>
{/* form fields */}
<Button disabled={createTask.isPending}>
{createTask.isPending ? 'Creating...' : 'Create'}
</Button>
</form>
)
}
function useEntityMutations(entityConfig: EntityConfig) {
const queryClient = useQueryClient()
const baseQueryKey = ['entity', entityConfig.slug]
const createMutation = useMutation({
mutationFn: async (data: Record<string, unknown>) => {
const response = await fetch(`/api/v1/${entityConfig.slug}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) throw new Error('Failed to create')
return response.json()
},
// OPTIMISTIC UPDATE
onMutate: async (newItem) => {
// 1. Cancel outgoing refetches to avoid overwriting optimistic update
await queryClient.cancelQueries({ queryKey: baseQueryKey })
// 2. Snapshot current data for rollback
const previousData = queryClient.getQueriesData({ queryKey: baseQueryKey })
// 3. Optimistically update all matching queries
queryClient.setQueriesData({ queryKey: baseQueryKey }, (old: any) => {
if (!old?.items) return old
return {
...old,
items: [
{ ...newItem, id: `temp-${Date.now()}` }, // Temporary ID
...old.items
],
total: old.total + 1,
}
})
// 4. Return context for rollback
return { previousData }
},
// ROLLBACK ON ERROR
onError: (error, variables, context) => {
if (context?.previousData) {
context.previousData.forEach(([queryKey, data]) => {
queryClient.setQueryData(queryKey, data)
})
}
toast.error('Failed to create item')
},
// SYNC WITH SERVER
onSettled: () => {
// Always refetch after mutation to sync with server
queryClient.invalidateQueries({ queryKey: baseQueryKey })
},
})
const updateMutation = useMutation({
mutationFn: async ({ id, data }: { id: string; data: Record<string, unknown> }) => {
const response = await fetch(`/api/v1/${entityConfig.slug}/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) throw new Error('Failed to update')
return response.json()
},
onMutate: async ({ id, data }) => {
await queryClient.cancelQueries({ queryKey: baseQueryKey })
const previousData = queryClient.getQueriesData({ queryKey: baseQueryKey })
// Update item in all matching queries
queryClient.setQueriesData({ queryKey: baseQueryKey }, (old: any) => {
if (!old?.items) return old
return {
...old,
items: old.items.map((item: any) =>
item.id === id ? { ...item, ...data } : item
),
}
})
return { previousData }
},
onError: (error, variables, context) => {
if (context?.previousData) {
context.previousData.forEach(([queryKey, data]) => {
queryClient.setQueryData(queryKey, data)
})
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: baseQueryKey })
},
})
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
const response = await fetch(`/api/v1/${entityConfig.slug}/${id}`, {
method: 'DELETE',
})
if (!response.ok) throw new Error('Failed to delete')
return response.json()
},
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: baseQueryKey })
const previousData = queryClient.getQueriesData({ queryKey: baseQueryKey })
// Remove item from all matching queries
queryClient.setQueriesData({ queryKey: baseQueryKey }, (old: any) => {
if (!old?.items) return old
return {
...old,
items: old.items.filter((item: any) => item.id !== id),
total: old.total - 1,
}
})
return { previousData }
},
onError: (error, variables, context) => {
if (context?.previousData) {
context.previousData.forEach(([queryKey, data]) => {
queryClient.setQueryData(queryKey, data)
})
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: baseQueryKey })
},
})
return {
create: createMutation,
update: updateMutation,
delete: deleteMutation,
}
}
Affects all queries with matching prefix:
// Invalidate all task queries (any filters)
queryClient.invalidateQueries({ queryKey: ['entity', 'tasks'] })
// Invalidate all entity queries
queryClient.invalidateQueries({ queryKey: ['entity'] })
Target specific queries:
// Invalidate single task
queryClient.invalidateQueries({
queryKey: ['entity', 'tasks', 'task-123']
})
// Invalidate list with specific filters
queryClient.invalidateQueries({
queryKey: ['entity', 'tasks', { status: 'active' }],
exact: true // Only exact match
})
Update without refetch:
// Update single item in cache
queryClient.setQueryData(
['entity', 'tasks', 'task-123'],
(old) => ({ ...old, status: 'completed' })
)
// Update all matching queries
queryClient.setQueriesData(
{ queryKey: ['entity', 'tasks'] },
(old: any) => ({
...old,
items: old.items.map((item: any) =>
item.id === 'task-123' ? { ...item, status: 'completed' } : item
),
})
)
1. Server State → TanStack Query (useQuery, useMutation)
2. URL State → Search params (shareable, bookmarkable)
3. Component State → useState (local, ephemeral)
4. Context API → Cross-component (theme, auth, user)
5. External Stores → useSyncExternalStore (third-party)
Rule: Use TanStack Query for ALL server data. Don't store server data in useState.
function TaskList() {
const { data, isLoading, isError, error, refetch } = useTaskList(filters)
if (isLoading) {
return <Skeleton count={5} />
}
if (isError) {
return (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>
{error.message}
<Button onClick={() => refetch()}>Retry</Button>
</AlertDescription>
</Alert>
)
}
return (
<ul>
{data.items.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</ul>
)
}
| Property | Description |
|----------|-------------|
| isLoading | First fetch, no data yet |
| isFetching | Any fetch (including background) |
| isPending | Mutation in progress |
| isError | Query/mutation failed |
| isSuccess | Query/mutation succeeded |
| data | Query result |
| error | Error object |
// ❌ NEVER DO THIS
function TaskList() {
const [tasks, setTasks] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
setLoading(true)
fetch('/api/v1/tasks')
.then(res => res.json())
.then(data => {
setTasks(data.items)
setLoading(false)
})
}, [])
// Problems: No caching, no error handling, no refetch, race conditions
}
// ✅ CORRECT
function TaskList() {
const { data, isLoading, error } = useQuery({
queryKey: ['entity', 'tasks'],
queryFn: () => fetch('/api/v1/tasks').then(res => res.json())
})
// Benefits: Caching, error handling, automatic refetch, deduplication
}
// ❌ NEVER DO THIS
function TaskStats({ tasks }) {
const [completedCount, setCompletedCount] = useState(0)
useEffect(() => {
setCompletedCount(tasks.filter(t => t.status === 'completed').length)
}, [tasks])
}
// ✅ CORRECT - Calculate during render
function TaskStats({ tasks }) {
const completedCount = useMemo(
() => tasks.filter(t => t.status === 'completed').length,
[tasks]
)
}
// ❌ NEVER DO THIS
function TaskPage() {
const { data } = useTaskList()
const [tasks, setTasks] = useState([])
useEffect(() => {
if (data) setTasks(data.items)
}, [data])
// Now have TWO sources of truth!
}
// ✅ CORRECT - Use query data directly
function TaskPage() {
const { data } = useTaskList()
const tasks = data?.items ?? []
// Single source of truth
}
// ❌ WRONG - Same key regardless of filters
useQuery({
queryKey: ['tasks'],
queryFn: () => fetch(`/api/v1/tasks?status=${status}`)
})
// ✅ CORRECT - Include filters in key
useQuery({
queryKey: ['tasks', { status }],
queryFn: () => fetch(`/api/v1/tasks?status=${status}`)
})
| Aspect | Convention |
|--------|-----------|
| Query Keys | ['domain', 'resource', filters, id] |
| Stale Time | 60s (global), 5min (entity lists) |
| GC Time | 1 hour |
| Retry | 2 attempts |
| Window Refetch | Disabled |
| Enabled Guard | enabled: !!user && conditions |
| Optimistic IDs | temp-${Date.now()} |
| Error Handling | Throw in queryFn, toast in onError |
Before finalizing data fetching:
entity-api - API endpoint patternsshadcn-components - Loading/error UI componentsreact-patterns - React best practicesdevelopment
Zod validation patterns for this Next.js application. Covers schema definition, API validation, form integration, error formatting, and type inference. Use this skill when implementing validation for APIs, forms, or entity schemas.
development
Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
testing
Test coverage metrics and registry system for this Next.js application. Covers FEATURE_REGISTRY, FLOW_REGISTRY, TAGS_REGISTRY, and coverage metrics interpretation. Use this skill when evaluating test coverage, identifying gaps, or planning testing priorities.
development
Tailwind CSS theming system for this Next.js application. Covers CSS variables, semantic tokens, dark mode, buildSectionClasses, and theme build process. Use this skill when implementing UI styling or working with theme configurations.