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