
State Management
- 276 installs
- 60 repo stars
- Updated May 16, 2026
- asyrafhussin/agent-skills
Design predictable client state for React or similar frontends using stores, reducers, and async flows so UI stays consistent across routes, forms, and shared data.
About
Covers frontend state management patterns for modern web and mobile clients, helping developers structure stores, handle async updates, and keep UI state consistent across components and navigation.
- Client store architecture
- Predictable state updates
- Async data flow patterns
- Component-level state boundaries
- Scalable frontend data modeling
State Management by the numbers
- 276 all-time installs (skills.sh)
- Ranked #770 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/asyrafhussin/agent-skills --skill state-managementAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 276 |
|---|---|
| repo stars | ★ 60 |
| Last updated | May 16, 2026 |
| Repository | asyrafhussin/agent-skills ↗ |
What it does
Design predictable client state for React or similar frontends using stores, reducers, and async flows so UI stays consistent across routes, forms, and shared data.
Files
State Management with React Query + Zustand
Version 1.1.0 | TanStack Query v5 | Zustand v5 | March 2026
Note:
This document provides comprehensive patterns for AI agents and LLMs working with
TanStack Query v5 and Zustand v5. All examples are verified against v5 APIs.
Optimized for automated refactoring, code generation, and state management best practices.
v5 Breaking Changes (Quick Reference)
TanStack Query v5:
cacheTime→gcTimekeepPreviousDataoption →placeholderData: keepPreviousData(imported helper)isPreviousData→isPlaceholderDataonSuccess/onError/onSettledremoved fromuseQuery— still valid onuseMutationsuspense: trueonuseQueryremoved → useuseSuspenseQuery
Zustand v5:
shallowas 2nd arg removed →useShallowfromzustand/shallow- Selectors returning new references need
useShallowto avoid infinite loops
Security: Persist Middleware
Never persist auth tokens, passwords, or secrets to localStorage/sessionStorage.
Use partialize to persist only non-sensitive state. Manage tokens via HttpOnly cookies.---
Comprehensive patterns for server state (React Query) and client state (Zustand). Contains 26+ rules for efficient data fetching and state management.
When to Apply
Reference these guidelines when:
- Fetching data from APIs
- Managing server state and caching
- Handling mutations and optimistic updates
- Creating client-side stores
- Combining React Query with Zustand
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | React Query Basics | CRITICAL | rq- |
| 2 | Zustand Store Patterns | CRITICAL | zs- |
| 3 | Caching & Invalidation | HIGH | cache- |
| 4 | Mutations & Updates | HIGH | mut- |
| 5 | Optimistic Updates | MEDIUM | opt- |
| 6 | DevTools & Debugging | MEDIUM | dev- |
| 7 | Advanced Patterns | LOW | adv- |
Quick Reference
1. React Query Basics (CRITICAL)
rq-setup- QueryClient and Provider setuprq-usequery- Basic useQuery patternsrq-querykeys- Query key organizationrq-loading-error- Handle loading and error statesrq-enabled- Conditional queries
2. Zustand Store Patterns (CRITICAL)
zs-create-store- Create basic storezs-typescript- TypeScript store patternszs-selectors- Efficient selectorszs-actions- Action patternszs-persist- Persist state to storage
3. Caching & Invalidation (HIGH)
cache-stale-time- Configure stale timecache-gc-time- Configure garbage collectioncache-invalidation- Invalidate queriescache-prefetch- Prefetch datacache-initial-data- Set initial data
4. Mutations & Updates (HIGH)
mut-usemutation- Basic useMutationmut-callbacks- onSuccess, onError callbacksmut-invalidate- Invalidate after mutationmut-update-cache- Direct cache updates
5. Optimistic Updates (MEDIUM)
opt-basic- Basic optimistic updatesopt-rollback- Rollback on erroropt-variables- Use mutation variables
6. DevTools & Debugging (MEDIUM)
dev-react-query- React Query DevToolsdev-zustand- Zustand DevToolsdev-debugging- Debug strategies
7. Advanced Patterns (LOW)
adv-infinite-queries- Infinite scrollingadv-parallel-queries- Parallel requestsadv-dependent-queries- Dependent queriesadv-query-zustand- Combine RQ with Zustand
React Query Patterns
Setup
// lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 30, // 30 minutes (formerly cacheTime)
retry: 1,
refetchOnWindowFocus: false,
},
},
})
// App.tsx
import { QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { queryClient } from './lib/queryClient'
function App() {
return (
<QueryClientProvider client={queryClient}>
<Router />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}Query Keys Factory
// lib/queryKeys.ts
export const queryKeys = {
// All posts
posts: {
all: ['posts'] as const,
lists: () => [...queryKeys.posts.all, 'list'] as const,
list: (filters: PostFilters) =>
[...queryKeys.posts.lists(), filters] as const,
details: () => [...queryKeys.posts.all, 'detail'] as const,
detail: (id: number) => [...queryKeys.posts.details(), id] as const,
},
// All users
users: {
all: ['users'] as const,
detail: (id: number) => [...queryKeys.users.all, id] as const,
posts: (userId: number) => [...queryKeys.users.all, userId, 'posts'] as const,
},
}useQuery Hook
// hooks/usePosts.ts
import { useQuery } from '@tanstack/react-query'
import { queryKeys } from '@/lib/queryKeys'
import { fetchPosts, fetchPost } from '@/api/posts'
export function usePosts(filters?: PostFilters) {
return useQuery({
queryKey: queryKeys.posts.list(filters ?? {}),
queryFn: () => fetchPosts(filters),
})
}
export function usePost(id: number) {
return useQuery({
queryKey: queryKeys.posts.detail(id),
queryFn: () => fetchPost(id),
enabled: !!id, // Only run if id exists
})
}
// Usage in component
function PostList() {
const { data: posts, isLoading, error } = usePosts()
if (isLoading) return <Spinner />
if (error) return <Error message={error.message} />
return (
<ul>
{posts?.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}useMutation Hook
// hooks/useCreatePost.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { queryKeys } from '@/lib/queryKeys'
import { createPost } from '@/api/posts'
export function useCreatePost() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createPost,
onSuccess: (newPost) => {
// Invalidate and refetch posts list
queryClient.invalidateQueries({
queryKey: queryKeys.posts.lists(),
})
},
onError: (error) => {
console.error('Failed to create post:', error)
},
})
}
// Usage
function CreatePostForm() {
const { mutate, isPending } = useCreatePost()
const handleSubmit = (data: CreatePostData) => {
mutate(data)
}
return (
<form onSubmit={handleSubmit}>
{/* form fields */}
<button disabled={isPending}>
{isPending ? 'Creating...' : 'Create'}
</button>
</form>
)
}Optimistic Updates
export function useUpdatePost() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updatePost,
onMutate: async (updatedPost) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({
queryKey: queryKeys.posts.detail(updatedPost.id),
})
// Snapshot previous value
const previousPost = queryClient.getQueryData(
queryKeys.posts.detail(updatedPost.id)
)
// Optimistically update
queryClient.setQueryData(
queryKeys.posts.detail(updatedPost.id),
updatedPost
)
return { previousPost }
},
onError: (err, updatedPost, context) => {
// Rollback on error
queryClient.setQueryData(
queryKeys.posts.detail(updatedPost.id),
context?.previousPost
)
},
onSettled: (data, error, variables) => {
// Refetch after settle
queryClient.invalidateQueries({
queryKey: queryKeys.posts.detail(variables.id),
})
},
})
}Zustand Patterns
Basic Store
// stores/useCounterStore.ts
import { create } from 'zustand'
interface CounterState {
count: number
increment: () => void
decrement: () => void
reset: () => void
}
export const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}))
// Usage
function Counter() {
const { count, increment, decrement } = useCounterStore()
return (
<div>
<span>{count}</span>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
)
}Store with TypeScript and Middleware
// stores/useAuthStore.ts
import { create } from 'zustand'
import { persist, devtools } from 'zustand/middleware'
interface User {
id: number
name: string
email: string
}
interface AuthState {
user: User | null
isAuthenticated: boolean
login: (user: User) => void
logout: () => void
}
// ✅ Never persist tokens to localStorage — use HttpOnly cookies server-side
export const useAuthStore = create<AuthState>()(
devtools(
persist(
(set) => ({
user: null,
isAuthenticated: false,
login: (user) =>
set({
user,
isAuthenticated: true,
}),
logout: () =>
set({
user: null,
isAuthenticated: false,
}),
}),
{
name: 'auth-storage',
// Only persist display info and auth flag — tokens must NOT be included
partialize: (state) => ({
user: state.user,
isAuthenticated: state.isAuthenticated,
}),
}
)
)
)Selectors for Performance
// Use selectors to prevent unnecessary re-renders
function UserName() {
// Only re-renders when user.name changes
const name = useAuthStore((state) => state.user?.name)
return <span>{name}</span>
}
// Multiple selectors
function UserInfo() {
const user = useAuthStore((state) => state.user)
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
if (!isAuthenticated) return <LoginButton />
return <span>{user?.name}</span>
}Combining React Query + Zustand
// Server state: React Query (what comes from API)
const { data: posts } = usePosts()
// Client state: Zustand (UI state)
const { selectedPostId, selectPost } = useUIStore()
// Use together
const selectedPost = posts?.find((p) => p.id === selectedPostId)How to Use
Read individual rule files for detailed explanations and code examples:
rules/rq-usequery.md
rules/rq-query-keys.md
rules/rq-mutation-setup.md
rules/rq-optimistic-updates.md
rules/zs-create-store.md
rules/zs-persist.md
rules/rq-query-invalidation.md
rules/rq-prefetching.md---
References
React Query (TanStack Query)
1. TanStack Query Documentation 2. React Query Overview 3. Queries Guide 4. Mutations Guide 5. Query Keys Guide 6. Optimistic Updates 7. Infinite Queries 8. Paginated Queries 9. React Query DevTools
Zustand
1. Zustand Demo 2. Zustand GitHub 3. Getting Started 4. TypeScript Guide 5. Persisting Store Data 6. Zustand Recipes
---
License
This skill is provided as-is for educational and development purposes. React Query is MIT licensed by TanStack. Zustand is MIT licensed by Poimandres (pmnd.rs).
State Management Patterns
Version 1.1.0 | TanStack Query v5 | Zustand v5 | March 2026
Note:
This document is designed for AI agents and LLMs when implementing, refactoring,
or generating state management code using TanStack Query v5 and Zustand v5.
All patterns verified against v5 APIs. Optimized for automated workflows and consistent patterns.
v5 Breaking Changes (Quick Reference)
TanStack Query v5:
cacheTime→gcTimekeepPreviousDataoption →placeholderData: keepPreviousData(imported helper)isPreviousData→isPlaceholderDataonSuccess/onError/onSettledremoved fromuseQuery— still valid onuseMutationsuspense: trueonuseQueryremoved → useuseSuspenseQuery
Zustand v5:
shallowas 2nd arg removed →useShallowfromzustand/shallow- Selectors returning new object/array references need
useShallowto avoid infinite loops
Security: Persist Middleware
Never persist auth tokens, passwords, or secrets to localStorage/sessionStorage.
These are accessible to any JavaScript — XSS fully exposes them.
Use partialize to include only non-sensitive state. Manage tokens via HttpOnly cookies server-side.---
Abstract
Comprehensive guide for React Query (TanStack Query) and Zustand state management patterns, designed for AI agents and LLMs. Contains 26+ rules across 6 categories, prioritized by impact from critical (query fundamentals, mutations, stores) to medium (advanced patterns, caching strategies). Each rule includes detailed explanations with bad vs. good code examples, TypeScript patterns, and specific use cases. Covers server state with React Query (data fetching, caching, mutations, optimistic updates) and client state with Zustand (stores, persistence, selectors). Optimized for automated refactoring and state management best practices.
---
Table of Contents
1. Query Fundamentals — CRITICAL
- 1.1 useQuery Hook Patterns
- 1.2 Query Keys Best Practices
- 1.3 Query Functions Best Practices
- 1.4 Query Conditional Execution
- 1.5 Query Data Transformation
2. Mutation & Updates — CRITICAL
- 2.1 Mutation Setup Best Practices
- 2.2 Mutation Callbacks
- 2.3 Optimistic Updates
- 2.4 Mutation Variables Pattern
- 2.5 Mutation Side Effects
3. Zustand Stores — CRITICAL
- 3.1 Creating Zustand Stores
- 3.2 Persist Middleware
4. Advanced Queries — HIGH
- 4.1 Infinite Queries Pattern
- 4.2 Paginated Queries
- 4.3 Dependent Queries Pattern
- 4.4 Parallel Queries Pattern
- 4.5 Query Cancellation
- 4.6 Suspense Mode Integration
5. Cache & Performance — HIGH-MEDIUM
- 5.1 Stale Time Configuration
- 5.2 Cache Time Configuration
- 5.3 Query Invalidation
- 5.4 Data Prefetching
- 5.5 Retry Logic Configuration
- 5.6 Placeholder Data Pattern
- 5.7 Initial Data Configuration
- 5.8 Refetch Configuration
6. DevTools & Patterns — MEDIUM
- 6.1 React Query DevTools
- 6.2 Zustand DevTools
- 6.3 Testing Strategies
- 6.4 Common Pitfalls
---
1. Query Fundamentals
Impact: CRITICAL
Query fundamentals are essential for any React Query implementation. These patterns form the foundation of server state management, covering data fetching, caching, and type safety.
Key Principles
- Query Keys: Use hierarchical, serializable arrays for cache organization
- Query Functions: Pure functions that throw on errors
- Conditional Execution: Use
enabledoption for dependent logic - Data Transformation: Use
selectfor derived data - Type Safety: Leverage TypeScript for query responses
When to Apply
- Starting any new React Query implementation
- Refactoring existing data fetching code
- Setting up QueryClient configuration
- Creating query key factories
Example: Basic Setup
// Query key factory
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: string) => [...userKeys.details(), id] as const,
}
// Query function
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) throw new Error('Failed to fetch user')
return response.json()
}
// Usage
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useQuery({
queryKey: userKeys.detail(userId),
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000,
})
if (isLoading) return <Spinner />
if (error) return <Error message={error.message} />
if (!user) return null
return <div>{user.name}</div>
}---
2. Mutation & Updates
Impact: CRITICAL
Mutations handle all write operations (create, update, delete). Proper mutation patterns ensure data consistency, instant user feedback, and graceful error handling.
Key Principles
- Error Handling: Always handle onError with user feedback
- Cache Updates: Invalidate or update cache after mutations
- Optimistic Updates: Provide instant feedback with rollback
- Type Safety: Type mutation variables and responses
- Callbacks: Use onSuccess, onError, onSettled appropriately
When to Apply
- Implementing create/update/delete operations
- Adding user feedback for actions
- Optimizing perceived performance
- Ensuring data consistency
Example: Complete Mutation
function useUpdateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (user: UpdateUserInput) => updateUserApi(user),
onMutate: async (updatedUser) => {
// Cancel outgoing queries
await queryClient.cancelQueries({ queryKey: userKeys.detail(updatedUser.id) })
// Snapshot for rollback
const previousUser = queryClient.getQueryData(userKeys.detail(updatedUser.id))
// Optimistic update
queryClient.setQueryData(userKeys.detail(updatedUser.id), updatedUser)
return { previousUser }
},
onError: (error, variables, context) => {
// Rollback
if (context?.previousUser) {
queryClient.setQueryData(userKeys.detail(variables.id), context.previousUser)
}
toast.error('Failed to update user')
},
onSuccess: (data) => {
toast.success('User updated successfully')
},
onSettled: (data, error, variables) => {
// Refetch to ensure server state
queryClient.invalidateQueries({ queryKey: userKeys.detail(variables.id) })
},
})
}---
3. Zustand Stores
Impact: CRITICAL
Zustand provides lightweight, performant client-side state management. Use it for UI state, user preferences, and any local-first data that doesn't come from the server.
Key Principles
- Minimal Boilerplate: Simple create() function
- TypeScript First: Explicit typing for state and actions
- Selectors: Use selectors to prevent unnecessary re-renders
- Middleware: Use persist, devtools for enhanced functionality
- Separation: Server state in React Query, client state in Zustand
When to Apply
- Managing UI state (modals, sidebars, selected items)
- User preferences and settings
- Form state across multiple steps
- Any client-only state
Example: Complete Store
interface TodoStore {
todos: Todo[]
filter: 'all' | 'active' | 'completed'
addTodo: (text: string) => void
toggleTodo: (id: string) => void
removeTodo: (id: string) => void
setFilter: (filter: TodoStore['filter']) => void
}
export const useTodoStore = create<TodoStore>()(
devtools(
persist(
(set) => ({
todos: [],
filter: 'all',
addTodo: (text) => set((state) => ({
todos: [...state.todos, {
id: crypto.randomUUID(),
text,
completed: false,
}],
})),
toggleTodo: (id) => set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
),
})),
removeTodo: (id) => set((state) => ({
todos: state.todos.filter((todo) => todo.id !== id),
})),
setFilter: (filter) => set({ filter }),
}),
{ name: 'todo-storage' }
)
)
)
// Usage with selectors
function TodoList() {
const todos = useTodoStore((state) => state.todos)
const filter = useTodoStore((state) => state.filter)
const toggleTodo = useTodoStore((state) => state.toggleTodo)
const filteredTodos = useMemo(() => {
switch (filter) {
case 'active': return todos.filter(t => !t.completed)
case 'completed': return todos.filter(t => t.completed)
default: return todos
}
}, [todos, filter])
return (
<ul>
{filteredTodos.map((todo) => (
<li key={todo.id} onClick={() => toggleTodo(todo.id)}>
{todo.text}
</li>
))}
</ul>
)
}---
4. Advanced Queries
Impact: HIGH
Advanced query patterns enable sophisticated UX like infinite scroll, pagination, complex data relationships, and optimized parallel loading.
Key Principles
- Infinite Queries: Use useInfiniteQuery for load-more patterns
- Pagination: Track page state explicitly
- Dependencies: Use enabled for sequential queries
- Parallelism: Fetch independent data simultaneously
- Cancellation: Clean up abandoned requests
When to Apply
- Implementing infinite scroll or load more
- Building paginated tables or lists
- Fetching related data sequentially
- Optimizing initial page load with parallel queries
Example: Infinite Query
function useInfinitePosts(category: string) {
return useInfiniteQuery({
queryKey: ['posts', 'infinite', category],
queryFn: async ({ pageParam }): Promise<PostsPage> => {
const response = await fetch(
`/api/posts?category=${category}&cursor=${pageParam}`
)
if (!response.ok) throw new Error('Failed to fetch')
return response.json()
},
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
})
}
function PostList({ category }: { category: string }) {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfinitePosts(category)
const allPosts = data?.pages.flatMap(page => page.posts) ?? []
return (
<div>
{allPosts.map(post => <PostCard key={post.id} post={post} />)}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
)}
</div>
)
}---
5. Cache & Performance
Impact: HIGH-MEDIUM
Caching strategies have high impact on perceived performance and server load. Proper configuration reduces network requests while ensuring data freshness.
Key Principles
- staleTime: Configure based on data volatility
- gcTime: Keep data in cache longer than staleTime
- Invalidation: Invalidate on mutations and user actions
- Prefetching: Anticipate user navigation
- Retry: Configure based on operation criticality
When to Apply
- Optimizing existing applications
- Reducing server load
- Improving perceived performance
- Handling unreliable networks
Example: Caching Strategy
// Global configuration
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute default
gcTime: 5 * 60 * 1000, // 5 minutes
retry: 1,
refetchOnWindowFocus: false,
},
},
})
// Per-query overrides
const staticQuery = {
staleTime: Infinity,
gcTime: Infinity,
}
const realtimeQuery = {
staleTime: 0,
refetchInterval: 30 * 1000,
}
// Usage
const { data: countries } = useQuery({
queryKey: ['countries'],
queryFn: fetchCountries,
...staticQuery,
})
const { data: notifications } = useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
...realtimeQuery,
})---
6. DevTools & Patterns
Impact: MEDIUM
DevTools and debugging patterns help identify performance issues and streamline development workflows.
Key Principles
- React Query DevTools: Visual cache inspection
- Zustand DevTools: Redux DevTools integration
- Testing: Mock query client for tests
- Error Boundaries: Catch query errors
- Suspense: React 18+ integration
When to Apply
- Debugging cache issues
- Understanding query behavior
- Setting up testing infrastructure
- Implementing error handling
---
References
React Query (TanStack Query)
1. TanStack Query Documentation 2. React Query Overview 3. Queries Guide 4. Mutations Guide 5. Query Keys Guide 6. Optimistic Updates 7. Infinite Queries 8. Paginated Queries 9. React Query DevTools
Zustand
1. Zustand Demo 2. Zustand GitHub 3. Getting Started 4. TypeScript Guide 5. Persisting Store Data 6. Zustand Recipes
---
License
This skill is provided as-is for educational and development purposes. React Query is MIT licensed by TanStack. Zustand is MIT licensed by Poimandres (pmnd.rs).
{
"version": "1.1.0",
"organization": "State Management Patterns",
"date": "March 2026",
"abstract": "Comprehensive guide for TanStack Query v5 and Zustand v5 state management patterns, designed for AI agents and LLMs. Contains 26+ rules across 6 categories, prioritized by impact from critical (query fundamentals, mutations, stores) to medium (advanced patterns, caching strategies). All patterns verified against v5 APIs including gcTime, placeholderData, useSuspenseQuery, and useShallow. Covers server state with TanStack Query (data fetching, caching, mutations, optimistic updates) and client state with Zustand (stores, secure persistence, selectors). Security: never persist auth tokens to localStorage — use HttpOnly cookies. Optimized for automated refactoring and state management best practices.",
"references": [
"https://tanstack.com/query/latest",
"https://tanstack.com/query/latest/docs/react/overview",
"https://tanstack.com/query/latest/docs/react/guides/queries",
"https://tanstack.com/query/latest/docs/react/guides/mutations",
"https://tanstack.com/query/latest/docs/react/guides/query-keys",
"https://tanstack.com/query/latest/docs/react/guides/optimistic-updates",
"https://tanstack.com/query/latest/docs/react/guides/infinite-queries",
"https://tanstack.com/query/latest/docs/react/guides/paginated-queries",
"https://tanstack.com/query/latest/docs/react/devtools",
"https://zustand-demo.pmnd.rs",
"https://github.com/pmndrs/zustand",
"https://docs.pmnd.rs/zustand/getting-started/introduction",
"https://docs.pmnd.rs/zustand/guides/typescript",
"https://docs.pmnd.rs/zustand/integrations/persisting-store-data",
"https://github.com/pmndrs/zustand#recipes"
],
"categories": [
{
"name": "React Query Fundamentals",
"prefix": "query",
"impact": "CRITICAL",
"description": "useQuery, query keys, enabled flag, and stale time"
},
{
"name": "Mutations",
"prefix": "mutation",
"impact": "CRITICAL",
"description": "useMutation, optimistic updates, cache invalidation"
},
{
"name": "Zustand Stores",
"prefix": "store",
"impact": "CRITICAL",
"description": "Store creation, slices, TypeScript, and persistence"
},
{
"name": "Advanced Patterns",
"prefix": "advanced",
"impact": "MEDIUM",
"description": "Infinite queries, paginated queries, prefetching, devtools"
}
],
"keyFeatures": [
"React Query (TanStack Query) for server state management",
"Zustand for lightweight client state",
"Optimistic updates with rollback patterns",
"Query key factories for consistent cache management",
"TypeScript patterns for type-safe stores and queries",
"Secure persistence with zustand/middleware (partialize to exclude sensitive data)",
"v5 API compatibility: gcTime, placeholderData, useSuspenseQuery, useShallow"
]
}
State Management (React Query + Zustand)
Version 1.1.0 | TanStack Query v5 | Zustand v5 | March 2026
Patterns for server state (TanStack Query v5) and client state (Zustand v5).
Overview
This skill provides guidance for:
- Data fetching with TanStack Query v5
- Caching and invalidation strategies
- Mutations and optimistic updates
- Client state with Zustand v5
- Combining both libraries
v5 Compatibility Notes
TanStack Query v5
- All APIs use single object argument:
useQuery({ queryKey, queryFn, ...options }) cacheTimerenamed togcTimekeepPreviousDataremoved → useplaceholderData: keepPreviousDataor identity functionisPreviousDataremoved → useisPlaceholderDataonSuccess,onError,onSettledcallbacks removed fromuseQuery(still valid onuseMutation)suspense: trueonuseQueryremoved → useuseSuspenseQuery
Zustand v5
shallowas 2nd argument to store hook removed → useuseShallowfromzustand/shallow- Selectors returning new object/array references may cause infinite loops — wrap with
useShallow
Security: Persist Middleware
Never persist auth tokens, passwords, or secrets to localStorage/sessionStorage.
These are accessible to any JavaScript on the page — an XSS attack fully exposes them.
Use partialize to persist only non-sensitive UI state. Manage auth tokens via HttpOnly cookies server-side.Categories
1. React Query Basics (Critical)
Setup, useQuery, query keys, and error handling.
2. Zustand Store Patterns (Critical)
Store creation, TypeScript, selectors, and secure persistence.
3. Caching & Invalidation (High)
Stale time, gc time, invalidation, and prefetching.
4. Mutations & Updates (High)
useMutation, callbacks, and cache updates.
5. Optimistic Updates (Medium)
Optimistic UI updates with rollback.
6. DevTools & Debugging (Medium)
Development tools for both libraries.
Server State vs Client State
| Server State (React Query) | Client State (Zustand) |
|---|---|
| Data from APIs | UI state |
| Cached remotely | Local only |
| May be stale | Always current |
| Needs refetching | No fetching |
| Examples: users, posts | Examples: modals, themes |
Quick Start
// React Query v5: Server state
const { data, isLoading } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
// Zustand v5: Client state
const useUIStore = create((set) => ({
isModalOpen: false,
openModal: () => set({ isModalOpen: true }),
closeModal: () => set({ isModalOpen: false }),
}))
// Zustand v5: Shallow selector (prevents infinite loops)
import { useShallow } from 'zustand/shallow'
const { count, text } = useStore(useShallow((s) => ({ count: s.count, text: s.text })))Usage
This skill triggers automatically when:
- Fetching data from APIs
- Managing application state
- Implementing caching
- Handling mutations
References
Rule Sections
Priority Levels
| Level | Description | When to Apply |
|---|---|---|
| CRITICAL | Essential for production apps | Always |
| HIGH | Significant performance impact | Most projects |
| MEDIUM | Noticeable improvements | When optimizing |
| LOW | Minor optimizations | Large-scale apps |
Section Overview
Query Fundamentals (CRITICAL)
Rules for basic React Query patterns. These are essential for any data fetching implementation, covering useQuery hooks, query keys, query functions, and conditional execution.
Impact: Critical foundation for server state management. Proper implementation prevents cache collisions, enables automatic refetching, and ensures type safety.
Key concepts:
- useQuery hook patterns and configuration
- Query key factory patterns
- Query function best practices
- Conditional query execution with
enabled - Data transformation with
select
Mutation & Updates (CRITICAL)
Rules for creating, updating, and deleting data with React Query mutations. Covers setup, callbacks, optimistic updates, and cache invalidation strategies.
Impact: Essential for write operations. Proper mutation handling ensures data consistency, provides instant user feedback, and handles errors gracefully.
Key concepts:
- useMutation setup and configuration
- Mutation callbacks (onSuccess, onError, onSettled)
- Optimistic updates with rollback
- Mutation variables and context
- Side effects and cache updates
Zustand Stores (CRITICAL)
Rules for client-side state management with Zustand. Covers store creation, TypeScript patterns, middleware, and integration with React Query.
Impact: Critical for managing UI state, user preferences, and local-first data. Zustand provides lightweight, performant state management without boilerplate.
Key concepts:
- Store creation and TypeScript patterns
- Selectors for performance optimization
- Persist middleware for localStorage
- DevTools integration
- Combining with React Query for hybrid state management
Advanced Queries (HIGH)
Rules for complex query patterns including infinite scrolling, pagination, dependent queries, and parallel fetching.
Impact: High value for data-heavy applications. These patterns enable sophisticated UX like infinite scroll, complex data relationships, and optimized parallel loading.
Key concepts:
- Infinite queries for load-more patterns
- Paginated queries with page management
- Dependent queries (sequential data fetching)
- Parallel queries for independent data
- Query cancellation and cleanup
Cache & Performance (HIGH-MEDIUM)
Rules for optimizing caching behavior, prefetching data, and configuring staleness. Covers staleTime, gcTime, invalidation, and retry logic.
Impact: High impact on perceived performance and server load. Proper caching reduces network requests while ensuring data freshness.
Key concepts:
- staleTime configuration for freshness
- gcTime (cache time) for memory management
- Query invalidation strategies
- Prefetching for anticipated navigation
- Retry logic and error recovery
- Placeholder and initial data patterns
- Refetch configuration
DevTools & Patterns (MEDIUM)
Rules for debugging, testing, and advanced patterns. Covers React Query DevTools, Zustand DevTools, Suspense integration, and best practices.
Impact: Medium impact on developer experience and debugging. These tools and patterns help identify performance issues and streamline development.
Key concepts:
- React Query DevTools usage
- Zustand DevTools integration
- Suspense mode for React 18+
- Testing strategies
- Common pitfalls and solutions
Section Relationships
Query Fundamentals → Everything else
↓
Mutation & Updates → Cache & Performance
↓
Advanced Queries → Cache & Performance
↓
Zustand Stores ← → All Query Patterns
↓
DevTools & Patterns (observes all)When to Apply Each Section
Starting a new project
1. Query Fundamentals - Set up QueryClient and basic patterns 2. Zustand Stores - Create stores for UI state 3. Mutation & Updates - Implement write operations 4. Cache & Performance - Configure caching strategies
Optimizing existing app
1. Cache & Performance - Audit staleTime/gcTime settings 2. Advanced Queries - Replace manual pagination with useInfiniteQuery 3. Mutation & Updates - Add optimistic updates for better UX 4. DevTools & Patterns - Use DevTools to identify issues
Scaling up
1. Advanced Queries - Implement dependent and parallel queries 2. Cache & Performance - Add prefetching for common paths 3. Zustand Stores - Extract complex component state to stores 4. DevTools & Patterns - Implement comprehensive testing
Rule Title Here
Impact: MEDIUM (optional impact description)
Brief explanation of the rule and why it matters. Focus on the problem it solves and the value it provides for state management.
Bad Example
// Anti-pattern: Description of what's wrong
const { data } = useQuery({
queryKey: ['data'],
queryFn: fetchData,
// Issues: explains the problems
})
// Another bad pattern example
const mutation = useMutation({
mutationFn: updateData,
// Missing important configuration
})Good Example
// Correct pattern: Description of what's right
const { data } = useQuery({
queryKey: queryKeys.data.list(filters),
queryFn: () => fetchData(filters),
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
})
// Another good pattern example
const mutation = useMutation({
mutationFn: updateData,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['data'] })
},
onError: (error) => {
console.error('Update failed:', error)
},
})
// With custom hook for reusability
export function useDataQuery(filters?: DataFilters) {
return useQuery({
queryKey: queryKeys.data.list(filters ?? {}),
queryFn: () => fetchData(filters),
staleTime: 5 * 60 * 1000,
})
}
// Zustand store example
interface DataStore {
items: Data[]
addItem: (item: Data) => void
removeItem: (id: string) => void
}
export const useDataStore = create<DataStore>((set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
removeItem: (id) => set((state) => ({
items: state.items.filter((i) => i.id !== id)
})),
}))Why
1. Performance: Explain the performance benefits with specific metrics or scenarios.
2. User Experience: Describe how this improves the user's experience.
3. Maintainability: Explain how this makes code easier to maintain and understand.
4. Type Safety: If applicable, describe TypeScript benefits.
5. Consistency: Explain how this promotes consistent patterns across the codebase.
6. Error Handling: Describe how this improves error handling and recovery.
When to use this pattern:
- Specific scenario 1
- Specific scenario 2
- Specific scenario 3
When NOT to use:
- Anti-scenario 1
- Anti-scenario 2
Reference: Link to relevant documentation
Cache Time (gcTime) Configuration
Impact: HIGH
Cache time (renamed to gcTime in v5) determines how long inactive query data remains in memory before garbage collection. This is different from staleTime.
Bad Example
// Anti-pattern: Confusing gcTime with staleTime
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
gcTime: 0, // Data immediately removed when component unmounts
// This causes refetch every time component remounts
});
// Anti-pattern: Infinite gcTime for large datasets
const { data: allProducts } = useQuery({
queryKey: ['products', 'all'],
queryFn: fetchAllProducts, // Returns thousands of items
gcTime: Infinity, // Memory leak potential
});
// Anti-pattern: Short gcTime with long staleTime
const { data } = useQuery({
queryKey: ['settings'],
queryFn: fetchSettings,
staleTime: 60 * 60 * 1000, // 1 hour
gcTime: 5 * 60 * 1000, // 5 minutes - data removed before it goes stale
});Good Example
// gcTime should generally be >= staleTime
const { data: user } = useQuery({
queryKey: userKeys.detail(userId),
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000, // Fresh for 5 minutes
gcTime: 30 * 60 * 1000, // Keep in cache for 30 minutes
});
// Static data can have infinite cache time
const { data: countries } = useQuery({
queryKey: ['countries'],
queryFn: fetchCountries,
staleTime: Infinity,
gcTime: Infinity, // Never garbage collect
});
// Large datasets with reasonable limits
const { data: products } = useQuery({
queryKey: ['products', { page, filters }],
queryFn: () => fetchProducts({ page, filters }),
staleTime: 2 * 60 * 1000, // 2 minutes
gcTime: 10 * 60 * 1000, // 10 minutes - reasonable for paginated data
});
// Configure sensible defaults
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
gcTime: 5 * 60 * 1000, // 5 minutes (default)
},
},
});
// Different cache strategies for different data types
const cacheStrategies = {
// Reference data - keep forever
static: {
staleTime: Infinity,
gcTime: Infinity,
},
// User-specific data - moderate caching
user: {
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
},
// List data - shorter caching
list: {
staleTime: 60 * 1000,
gcTime: 5 * 60 * 1000,
},
// Real-time data - minimal caching
realtime: {
staleTime: 0,
gcTime: 60 * 1000,
},
};
// Usage with strategies
const { data } = useQuery({
queryKey: ['users', 'list'],
queryFn: fetchUsers,
...cacheStrategies.list,
});Why
1. Memory Management: gcTime prevents memory leaks by removing unused data from the cache.
2. Performance: Keeping data in cache (even stale) allows instant display while refetching in the background.
3. Navigation Experience: Users navigating back to a page see cached data immediately, improving perceived performance.
4. Resource Efficiency: Proper gcTime balances memory usage with the benefits of caching.
5. Relationship with staleTime: gcTime should typically be longer than staleTime to benefit from background refetching.
Key differences:
staleTime: How long until data is considered stale (triggers background refetch)gcTime: How long inactive data stays in cache before garbage collection
Timeline example:
Query made -> Data fresh (staleTime period)
-> Data stale (will refetch on next access)
-> Component unmounts (gcTime countdown starts)
-> gcTime expires -> Data removed from cacheBest practices:
- gcTime >= staleTime (usually 2-5x longer)
- Consider data size when setting gcTime
- Use Infinity sparingly and only for small, truly static data
- Monitor memory usage in production
Dependent Queries
Impact: HIGH
Dependent queries are queries that rely on data from previous queries. They should only execute when their dependencies are available.
Bad Example
// Anti-pattern: Not disabling dependent query
function UserPosts({ userId }: { userId: string }) {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
// This runs immediately, even before user is loaded
const { data: posts } = useQuery({
queryKey: ['posts', user?.id], // user?.id is undefined initially
queryFn: () => fetchUserPosts(user!.id), // Crashes or fetches wrong data
});
}
// Anti-pattern: Early return breaking hooks
function ProfileWithPosts({ userId }: { userId: string }) {
const { data: user, isLoading } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
if (isLoading || !user) {
return <Loading />;
}
// This hook is conditionally called - breaks React rules!
const { data: posts } = useQuery({
queryKey: ['posts', user.teamId],
queryFn: () => fetchTeamPosts(user.teamId),
});
return <div>...</div>;
}
// Anti-pattern: Nested queries in callback
// ❌ onSuccess on useQuery was removed in TanStack Query v5 — do not use
function BadDependentQueries({ userId }: { userId: string }) {
const [posts, setPosts] = useState([]);
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
// onSuccess no longer exists in v5 — and even in v4 this was wrong:
// fetching inside callbacks has no caching, no loading state, no error handling
});
}Good Example
// Proper dependent query with enabled option
function UserPosts({ userId }: { userId: string }) {
const { data: user, isLoading: isUserLoading } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
const {
data: posts,
isLoading: isPostsLoading,
} = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetchUserPosts(user!.id),
enabled: !!user?.id, // Only fetch when user.id exists
});
if (isUserLoading) return <UserSkeleton />;
if (!user) return <NotFound />;
return (
<div>
<UserProfile user={user} />
{isPostsLoading ? (
<PostsSkeleton />
) : (
<PostList posts={posts} />
)}
</div>
);
}
// Multiple dependent queries
function ProjectDashboard({ projectId }: { projectId: string }) {
// Level 1: Project
const { data: project } = useQuery({
queryKey: ['project', projectId],
queryFn: () => fetchProject(projectId),
});
// Level 2: Depends on project
const { data: team } = useQuery({
queryKey: ['team', project?.teamId],
queryFn: () => fetchTeam(project!.teamId),
enabled: !!project?.teamId,
});
const { data: settings } = useQuery({
queryKey: ['project-settings', projectId],
queryFn: () => fetchProjectSettings(projectId),
enabled: !!project, // Only fetch if project exists
});
// Level 3: Depends on team
const { data: members } = useQuery({
queryKey: ['team-members', team?.id],
queryFn: () => fetchTeamMembers(team!.id),
enabled: !!team?.id,
});
return (
<Dashboard
project={project}
team={team}
settings={settings}
members={members}
/>
);
}
// Dependent query with combined loading state
function useUserWithPosts(userId: string) {
const userQuery = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
const postsQuery = useQuery({
queryKey: ['posts', userQuery.data?.id],
queryFn: () => fetchUserPosts(userQuery.data!.id),
enabled: !!userQuery.data?.id,
});
return {
user: userQuery.data,
posts: postsQuery.data,
isLoading: userQuery.isLoading || (userQuery.isSuccess && postsQuery.isLoading),
isError: userQuery.isError || postsQuery.isError,
error: userQuery.error || postsQuery.error,
};
}
// Dependent query with transformation
function OrderDetails({ orderId }: { orderId: string }) {
const { data: order } = useQuery({
queryKey: ['order', orderId],
queryFn: () => fetchOrder(orderId),
});
// Fetch all products for the order items
const { data: products } = useQuery({
queryKey: ['products', order?.items.map(i => i.productId)],
queryFn: () => fetchProducts(order!.items.map(i => i.productId)),
enabled: !!order?.items.length,
select: (products) => {
// Create lookup map for easy access
return new Map(products.map(p => [p.id, p]));
},
});
// Enrich order items with product details
const enrichedItems = order?.items.map(item => ({
...item,
product: products?.get(item.productId),
}));
return <OrderView order={order} items={enrichedItems} />;
}
// Parallel independent + dependent queries
function Dashboard({ userId }: { userId: string }) {
// These can run in parallel
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
const { data: notifications } = useQuery({
queryKey: ['notifications', userId],
queryFn: () => fetchNotifications(userId),
});
// This depends on user
const { data: recommendations } = useQuery({
queryKey: ['recommendations', user?.preferences],
queryFn: () => fetchRecommendations(user!.preferences),
enabled: !!user?.preferences,
});
return (
<div>
<Header user={user} notifications={notifications} />
<Recommendations items={recommendations} />
</div>
);
}Why
1. Avoid Invalid Requests: Dependent queries prevent fetching with undefined parameters.
2. Hook Rules: Using enabled instead of conditional returns keeps hooks unconditional.
3. Proper Caching: Each query has its own cache entry and can be invalidated independently.
4. Loading States: Each query's loading state can be tracked separately for granular UI feedback.
5. Error Boundaries: Errors in dependent queries don't affect parent queries.
6. Automatic Refetching: When parent data changes, dependent queries automatically re-execute.
Query states with enabled: false:
isPending: true (waiting to be enabled)fetchStatus: 'idle'status: 'pending'isLoading: false (not actively loading)
The query transitions to loading state only when enabled becomes true.
Enabled Option for Conditional Queries
Impact: CRITICAL
The enabled option controls whether a query should execute. It's essential for dependent queries, conditional fetching, and avoiding unnecessary requests.
Bad Example
// Anti-pattern: Fetching with undefined/null parameters
const { data: userPosts } = useQuery({
queryKey: ['posts', userId],
queryFn: () => fetchUserPosts(userId), // userId might be undefined
});
// Anti-pattern: Using early return to prevent query
function UserProfile({ userId }: { userId?: string }) {
if (!userId) {
return <div>Select a user</div>;
}
// This causes hook order issues when userId changes from undefined to defined
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
return <div>{data?.name}</div>;
}
// Anti-pattern: Complex conditions in queryFn
const { data } = useQuery({
queryKey: ['data', someCondition],
queryFn: async () => {
if (!someCondition) {
return null; // Returning null instead of disabling query
}
return fetchData();
},
});Good Example
// Dependent query - wait for userId
function UserProfile({ userId }: { userId?: string }) {
const { data, isLoading } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId!),
enabled: !!userId, // Only fetch when userId exists
});
if (!userId) {
return <div>Select a user</div>;
}
if (isLoading) {
return <div>Loading...</div>;
}
return <div>{data?.name}</div>;
}
// Chained/dependent queries
function UserPosts({ userId }: { userId: string }) {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetchUserPosts(user!.id),
enabled: !!user?.id, // Only fetch after user is loaded
});
return (
<div>
<h1>{user?.name}'s Posts</h1>
<PostList posts={posts} />
</div>
);
}
// Feature flag or permission-based queries
function AdminDashboard() {
const { user } = useAuth();
const { data: stats } = useQuery({
queryKey: ['admin', 'stats'],
queryFn: fetchAdminStats,
enabled: user?.role === 'admin', // Only fetch for admins
});
const { data: auditLogs } = useQuery({
queryKey: ['admin', 'audit-logs'],
queryFn: fetchAuditLogs,
enabled: user?.permissions.includes('view_audit_logs'),
});
return <Dashboard stats={stats} logs={auditLogs} />;
}
// Toggle-based fetching
function SearchResults() {
const [searchTerm, setSearchTerm] = useState('');
const [shouldSearch, setShouldSearch] = useState(false);
const { data, isFetching } = useQuery({
queryKey: ['search', searchTerm],
queryFn: () => search(searchTerm),
enabled: shouldSearch && searchTerm.length >= 3,
});
const handleSearch = () => {
if (searchTerm.length >= 3) {
setShouldSearch(true);
}
};
return (
<div>
<input
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setShouldSearch(false);
}}
/>
<button onClick={handleSearch} disabled={isFetching}>
Search
</button>
{data && <Results data={data} />}
</div>
);
}
// Multiple conditions
const { data } = useQuery({
queryKey: ['protected-data', resourceId],
queryFn: () => fetchProtectedData(resourceId),
enabled: Boolean(
isAuthenticated &&
hasPermission &&
resourceId &&
!isOffline
),
});Why
1. Avoid Invalid Requests: Prevents fetching with undefined or invalid parameters that would cause API errors.
2. Hook Rules Compliance: React hooks must be called unconditionally; enabled allows conditional execution without violating hook rules.
3. Query Dependencies: Enables proper sequencing of dependent queries where one query needs data from another.
4. Performance: Prevents unnecessary API calls when data isn't needed or conditions aren't met.
5. Authorization: Allows queries to be gated by authentication state or user permissions.
6. User Intent: Supports patterns where fetching should only occur after explicit user action.
Query states when enabled: false:
status: 'pending' (no data yet) or 'success' (has previous data)fetchStatus: 'idle' (not fetching)isLoading: false (even if no data)isPending: true (if no data) - use this to show placeholders
Infinite Queries for Paginated Lists
Impact: HIGH
Infinite queries handle "load more" patterns where data is fetched in pages and accumulated. They manage page parameters and provide seamless scrolling experiences.
Bad Example
// Anti-pattern: Manual infinite scroll with useQuery
function PostList() {
const [pages, setPages] = useState<Post[][]>([]);
const [pageParam, setPageParam] = useState(0);
const { data, isLoading } = useQuery({
queryKey: ['posts', pageParam],
queryFn: () => fetchPosts(pageParam),
});
// Manually managing accumulated pages
useEffect(() => {
if (data) {
setPages((prev) => [...prev, data.posts]);
}
}, [data]);
// Problems: duplicate posts, complex state management, no cache benefits
}
// Anti-pattern: Using cursor as regular query parameter
const { data } = useQuery({
queryKey: ['posts', cursor],
queryFn: () => fetchPosts(cursor),
// Each cursor creates separate cache entry
// Can't easily access all loaded pages
});
// Anti-pattern: Infinite query without proper getNextPageParam
const { data } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => fetchPosts(pageParam),
initialPageParam: 0,
getNextPageParam: () => undefined, // Always returns undefined - can't load more
});Good Example
// Proper infinite query setup
interface PostsResponse {
posts: Post[];
nextCursor: string | null;
hasMore: boolean;
}
function useInfinitePosts() {
return useInfiniteQuery({
queryKey: ['posts', 'infinite'],
queryFn: async ({ pageParam }): Promise<PostsResponse> => {
const response = await fetch(`/api/posts?cursor=${pageParam}`);
if (!response.ok) throw new Error('Failed to fetch posts');
return response.json();
},
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
getPreviousPageParam: (firstPage) => firstPage.prevCursor ?? undefined,
});
}
// Component with load more button
function PostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
} = useInfinitePosts();
if (isLoading) return <LoadingSpinner />;
if (isError) return <ErrorMessage />;
// Flatten pages into single array
const allPosts = data?.pages.flatMap((page) => page.posts) ?? [];
return (
<div>
{allPosts.map((post) => (
<PostCard key={post.id} post={post} />
))}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
}
// Infinite scroll with Intersection Observer
function InfinitePostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfinitePosts();
const loadMoreRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 0.1 }
);
if (loadMoreRef.current) {
observer.observe(loadMoreRef.current);
}
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
const allPosts = data?.pages.flatMap((page) => page.posts) ?? [];
return (
<div>
{allPosts.map((post) => (
<PostCard key={post.id} post={post} />
))}
<div ref={loadMoreRef} style={{ height: 20 }}>
{isFetchingNextPage && <LoadingSpinner />}
</div>
</div>
);
}
// Offset-based pagination
function useInfiniteProducts(category: string) {
const limit = 20;
return useInfiniteQuery({
queryKey: ['products', category, 'infinite'],
queryFn: async ({ pageParam }) => {
const offset = pageParam * limit;
const response = await fetch(
`/api/products?category=${category}&offset=${offset}&limit=${limit}`
);
return response.json();
},
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
// If last page has fewer items than limit, no more pages
return lastPage.products.length === limit ? allPages.length : undefined;
},
});
}
// Bidirectional infinite scroll
function useInfiniteMessages(channelId: string) {
return useInfiniteQuery({
queryKey: ['messages', channelId],
queryFn: ({ pageParam }) => fetchMessages(channelId, pageParam),
initialPageParam: { cursor: null, direction: 'backward' },
getNextPageParam: (lastPage) =>
lastPage.hasOlder ? { cursor: lastPage.oldestId, direction: 'backward' } : undefined,
getPreviousPageParam: (firstPage) =>
firstPage.hasNewer ? { cursor: firstPage.newestId, direction: 'forward' } : undefined,
maxPages: 10, // Limit memory usage
});
}
// With select for transformation
const { data } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: fetchPostsPage,
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextPage,
select: (data) => ({
pages: data.pages,
pageParams: data.pageParams,
// Add computed properties
totalCount: data.pages.reduce((sum, page) => sum + page.posts.length, 0),
allPosts: data.pages.flatMap((page) => page.posts),
}),
});Why
1. Memory Efficiency: Infinite queries store all pages efficiently in a single cache entry.
2. Seamless UX: Automatic page tracking enables smooth infinite scroll without manual state management.
3. Cache Benefits: All loaded pages share staleness, refetching updates the entire list consistently.
4. Bidirectional Loading: Support for both next and previous pages enables chat-like interfaces.
5. Memory Limits: maxPages option prevents memory issues with very long lists.
6. Refetch Behavior: Refetching an infinite query refetches all pages, ensuring data consistency.
Key properties:
data.pages: Array of page datadata.pageParams: Array of page parameters usedfetchNextPage(): Fetch the next pagefetchPreviousPage(): Fetch the previous pagehasNextPage: Boolean indicating if more pages existhasPreviousPage: Boolean for previous pagesisFetchingNextPage: Loading state for next pageisFetchingPreviousPage: Loading state for previous page
Initial Data for Cache Seeding
Impact: MEDIUM
Initial data seeds the query cache with known data before or instead of fetching. It's cached immediately and affects query staleness, unlike placeholderData.
Bad Example
// Anti-pattern: Using initialData for loading placeholders
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
initialData: { name: 'Loading...' }, // This is cached as real data!
// If staleTime > 0, this fake data will be served
});
// Anti-pattern: Initial data without staleTime consideration
const { data } = useQuery({
queryKey: ['product', productId],
queryFn: () => fetchProduct(productId),
initialData: partialProduct, // From list view
// Without initialDataUpdatedAt, React Query doesn't know how old this is
});
// Anti-pattern: Initial data that might be stale
function ProductDetail({ product }: { product: Product }) {
const { data } = useQuery({
queryKey: ['product', product.id],
queryFn: () => fetchProduct(product.id),
initialData: product,
staleTime: 5 * 60 * 1000, // 5 minutes
// Product from props might be hours old, but won't refetch for 5 minutes
});
return <ProductView product={data} />;
}Good Example
// SSR/SSG: Seed cache with server-fetched data
// In Next.js pages
export async function getServerSideProps() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
return {
props: {
dehydratedState: dehydrate(queryClient),
},
};
}
// Client component uses the pre-seeded cache
function UserProfile({ userId }: { userId: string }) {
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
// Data is already in cache from SSR
});
return <Profile user={data} />;
}
// Use initialData with proper staleness tracking
function ProductDetail({ listProduct }: { listProduct: ListProduct }) {
const { data: product } = useQuery({
queryKey: ['product', listProduct.id],
queryFn: () => fetchProduct(listProduct.id),
initialData: listProduct,
initialDataUpdatedAt: listProduct.fetchedAt, // Track when list was fetched
staleTime: 60 * 1000, // 1 minute
// Will refetch if listProduct.fetchedAt is older than staleTime
});
return <ProductView product={product} />;
}
// Seeding from another query's cache
function UserDetail({ userId }: { userId: string }) {
const queryClient = useQueryClient();
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
initialData: () => {
const usersCache = queryClient.getQueryData<User[]>(['users']);
return usersCache?.find(u => u.id === userId);
},
initialDataUpdatedAt: () => {
// Get the timestamp of the users list query
return queryClient.getQueryState(['users'])?.dataUpdatedAt;
},
});
return <UserProfile user={user} />;
}
// Conditional initial data based on source
interface ProductDetailProps {
productId: string;
initialProduct?: Product;
productTimestamp?: number;
}
function ProductDetail({
productId,
initialProduct,
productTimestamp,
}: ProductDetailProps) {
const { data } = useQuery({
queryKey: ['product', productId],
queryFn: () => fetchProduct(productId),
...(initialProduct && {
initialData: initialProduct,
initialDataUpdatedAt: productTimestamp,
}),
staleTime: 30 * 1000,
});
return <Product data={data} />;
}
// Pre-seeding cache on application load
async function initializeApp() {
const queryClient = new QueryClient();
// Pre-fetch critical data
await Promise.all([
queryClient.prefetchQuery({
queryKey: ['currentUser'],
queryFn: fetchCurrentUser,
}),
queryClient.prefetchQuery({
queryKey: ['config'],
queryFn: fetchAppConfig,
}),
]);
return queryClient;
}
// Direct cache manipulation for known data
function handleRouteData(data: RouteData) {
// Seed cache with data from route/navigation state
queryClient.setQueryData(['resource', data.id], data.resource, {
updatedAt: data.timestamp,
});
}Why
1. SSR Hydration: Initial data enables seamless hydration of server-rendered content into the React Query cache.
2. Navigation Optimization: Data from list views can seed detail view caches, eliminating loading states.
3. Offline Support: Known data can be pre-seeded to support offline-first experiences.
4. Perceived Performance: Instant data display from cache feels faster than waiting for network requests.
5. Staleness Tracking: initialDataUpdatedAt ensures proper refetching when seeded data is old.
6. Type Safety: Initial data must match the query return type, catching errors at compile time.
Initial Data vs Placeholder Data:
| Feature | initialData | placeholderData |
|---|---|---|
| Cached | Yes | No |
| Affects staleTime | Yes | No |
| Triggers background refetch | If stale | Always |
| Use case | Known valid data | Temporary UI |
| With updatedAt | Track staleness | N/A |
Best practices:
- Always use
initialDataUpdatedAtwhen initial data might be stale - Use
placeholderDatafor temporary display data - Use
initialDatafor valid data from another source (SSR, cache, route state) - Consider data completeness (list items may have fewer fields than detail views)
Mutation Callbacks
Impact: HIGH
Mutation callbacks (onSuccess, onError, onSettled, onMutate) provide hooks into the mutation lifecycle for side effects, cache updates, and error handling.
Bad Example
// Anti-pattern: Side effects in mutationFn
const mutation = useMutation({
mutationFn: async (data) => {
const result = await createUser(data);
toast.success('User created!'); // Side effect in mutationFn
queryClient.invalidateQueries({ queryKey: ['users'] }); // Cache update here
navigate('/users'); // Navigation here
return result;
},
});
// Anti-pattern: Duplicate callbacks in hook and mutate call
const mutation = useMutation({
mutationFn: createUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
// Later in component
mutation.mutate(data, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] }); // Duplicate!
navigate('/users');
},
});
// Anti-pattern: Not handling errors
const mutation = useMutation({
mutationFn: createUser,
onSuccess: (data) => {
navigate(`/users/${data.id}`);
},
// No onError - user has no idea if it failed
});Good Example
// Separate concerns: hook handles cache, component handles UI
function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createUser,
// Cache-related side effects in the hook
onSuccess: (newUser) => {
// Update cache
queryClient.invalidateQueries({ queryKey: ['users'] });
// Or optimistically add to cache
queryClient.setQueryData<User[]>(['users'], (old) =>
old ? [...old, newUser] : [newUser]
);
},
onError: (error) => {
// Log errors for monitoring
console.error('Create user failed:', error);
},
});
}
// Component handles UI-specific side effects
function CreateUserPage() {
const navigate = useNavigate();
const createUser = useCreateUser();
const handleSubmit = (data: CreateUserInput) => {
createUser.mutate(data, {
// UI-specific callbacks
onSuccess: (newUser) => {
toast.success(`Welcome, ${newUser.name}!`);
navigate(`/users/${newUser.id}`);
},
onError: (error) => {
toast.error(error.message);
},
});
};
return <UserForm onSubmit={handleSubmit} isLoading={createUser.isPending} />;
}
// All callbacks with proper typing
interface MutationContext {
previousUsers: User[] | undefined;
}
const mutation = useMutation<User, ApiError, CreateUserInput, MutationContext>({
mutationFn: createUser,
onMutate: async (variables) => {
// Called before mutationFn
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['users'] });
// Snapshot current value
const previousUsers = queryClient.getQueryData<User[]>(['users']);
// Return context for rollback
return { previousUsers };
},
onSuccess: (data, variables, context) => {
// data: returned from mutationFn
// variables: passed to mutate()
// context: returned from onMutate
console.log(`Created user ${data.name} with email ${variables.email}`);
},
onError: (error, variables, context) => {
// Rollback on error
if (context?.previousUsers) {
queryClient.setQueryData(['users'], context.previousUsers);
}
toast.error(`Failed to create user: ${error.message}`);
},
onSettled: (data, error, variables, context) => {
// Always runs after success or error
// Good place for cleanup
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
// Using mutateAsync with callbacks
async function handleSequentialMutations() {
try {
const user = await createUserMutation.mutateAsync(userData, {
onSuccess: () => {
// This runs before the promise resolves
console.log('User created, creating profile...');
},
});
await createProfileMutation.mutateAsync({ userId: user.id });
} catch (error) {
// Catches errors from either mutation
}
}
// Conditional callbacks based on response
const mutation = useMutation({
mutationFn: updateUser,
onSuccess: (updatedUser, variables) => {
if (updatedUser.requiresVerification) {
navigate('/verify-email');
} else if (variables.role !== updatedUser.role) {
// Role change requires re-login
logout();
} else {
queryClient.invalidateQueries({ queryKey: ['user', updatedUser.id] });
}
},
});Why
1. Separation of Concerns: Hook-level callbacks handle cache; component-level callbacks handle UI.
2. Callback Order: onMutate -> mutationFn -> onSuccess/onError -> onSettled. Understanding this enables proper optimistic updates.
3. Context Passing: onMutate can return context used by other callbacks for rollback or additional logic.
4. Callback Composition: Both hook and mutate() callbacks run; hook callbacks first, then mutate() callbacks.
5. Error Recovery: onError with context enables rolling back optimistic updates on failure.
6. Cleanup: onSettled runs regardless of outcome, perfect for cleanup and final cache invalidation.
Callback execution order:
mutate(variables) called
↓
onMutate(variables) → returns context
↓
mutationFn(variables) executes
↓
Success?
Yes → onSuccess(data, variables, context)
No → onError(error, variables, context)
↓
onSettled(data, error, variables, context)Note: Callbacks defined in both useMutation and mutate() both execute. Use hook-level for shared logic and mutate-level for component-specific logic.
Mutation Setup Best Practices
Impact: CRITICAL
Mutations handle data modifications (create, update, delete) in React Query. Proper setup ensures reliable data changes with appropriate error handling and cache updates.
Bad Example
// Anti-pattern: Using useQuery for mutations
const { refetch } = useQuery({
queryKey: ['createUser'],
queryFn: () => createUser(userData),
enabled: false,
});
// Calling refetch() to trigger - wrong approach
// Anti-pattern: No error handling
const mutation = useMutation({
mutationFn: createUser,
});
const handleSubmit = () => {
mutation.mutate(userData);
// Assuming success without checking
navigate('/users');
};
// Anti-pattern: Inline mutation function with no typing
const mutation = useMutation({
mutationFn: async (data) => {
const res = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(data),
});
return res.json(); // Not checking response status
},
});Good Example
// Define typed mutation functions separately
interface CreateUserInput {
name: string;
email: string;
role: 'admin' | 'user';
}
interface User {
id: string;
name: string;
email: string;
role: string;
createdAt: string;
}
async function createUser(input: CreateUserInput): Promise<User> {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new ApiError(response.status, error.message || 'Failed to create user');
}
return response.json();
}
// Basic mutation with proper setup
function CreateUserForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createUser,
onSuccess: (newUser) => {
// Invalidate users list to trigger refetch
queryClient.invalidateQueries({ queryKey: ['users'] });
},
onError: (error) => {
console.error('Failed to create user:', error);
},
});
const handleSubmit = (data: CreateUserInput) => {
mutation.mutate(data);
};
return (
<form onSubmit={handleSubmit}>
{/* Form fields */}
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create User'}
</button>
{mutation.isError && (
<div className="error">{mutation.error.message}</div>
)}
</form>
);
}
// Reusable mutation hook with all states handled
function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createUser,
onSuccess: (newUser) => {
queryClient.invalidateQueries({ queryKey: ['users'] });
toast.success(`User ${newUser.name} created successfully`);
},
onError: (error: ApiError) => {
toast.error(error.message);
},
onSettled: () => {
// Always runs after mutation completes (success or error)
},
});
}
// Usage with the custom hook
function UserManager() {
const createUser = useCreateUser();
return (
<div>
<CreateUserForm
onSubmit={createUser.mutate}
isLoading={createUser.isPending}
error={createUser.error}
/>
</div>
);
}
// Mutation with async/await for sequential operations
async function handleComplexOperation() {
try {
const user = await createUserMutation.mutateAsync(userData);
const profile = await createProfileMutation.mutateAsync({
userId: user.id,
...profileData,
});
navigate(`/users/${user.id}`);
} catch (error) {
// Handle any error in the chain
console.error('Operation failed:', error);
}
}Why
1. Separation from Queries: Mutations have different semantics - they modify data and should use useMutation, not useQuery.
2. Error Handling: Proper mutation setup includes error states that can be displayed to users.
3. Loading States: The isPending flag enables proper UI feedback during mutations.
4. Type Safety: Typed mutation functions catch errors at compile time and provide better developer experience.
5. Cache Management: Mutations should handle cache invalidation or updates to keep data consistent.
6. Reusability: Custom mutation hooks encapsulate logic and can be reused across components.
Key mutation properties:
mutate(): Fire and forget mutationmutateAsync(): Returns promise for sequential operationsisPending: Mutation is in progressisSuccess: Mutation completed successfullyisError: Mutation failederror: Error object if faileddata: Response data if successfulreset(): Reset mutation state
Mutation Side Effects
Impact: MEDIUM
Side effects are actions triggered by mutation results, such as cache updates, navigation, notifications, and analytics. Proper organization keeps mutations maintainable.
Bad Example
// Anti-pattern: All side effects in mutationFn
const mutation = useMutation({
mutationFn: async (data) => {
const result = await createUser(data);
// Side effects mixed with data fetching
queryClient.invalidateQueries({ queryKey: ['users'] });
toast.success('User created!');
analytics.track('user_created', { id: result.id });
router.push(`/users/${result.id}`);
localStorage.setItem('lastCreatedUser', result.id);
return result;
},
});
// Anti-pattern: Side effects that throw
const mutation = useMutation({
mutationFn: updateUser,
onSuccess: async (data) => {
await riskyAsyncOperation(); // If this throws, mutation appears to fail
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
// Anti-pattern: Duplicate side effects across components
// Component A
const mutation = useMutation({
mutationFn: updateUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
analytics.track('user_updated');
},
});
// Component B - duplicates the same side effects
const mutation = useMutation({
mutationFn: updateUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
analytics.track('user_updated');
},
});Good Example
// Centralize cache-related side effects in custom hook
function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateUserApi,
onSuccess: (updatedUser) => {
// Cache updates - always needed
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.setQueryData(['user', updatedUser.id], updatedUser);
},
onError: (error) => {
// Error logging - always needed
errorReporter.capture(error);
},
});
}
// Component adds UI-specific side effects
function EditUserPage({ userId }: { userId: string }) {
const navigate = useNavigate();
const updateUser = useUpdateUser();
const handleSubmit = (data: UserFormData) => {
updateUser.mutate(
{ id: userId, data },
{
// UI-specific side effects
onSuccess: (user) => {
toast.success(`${user.name} updated successfully`);
navigate(`/users/${user.id}`);
},
onError: (error) => {
toast.error(error.message);
},
}
);
};
return <UserForm onSubmit={handleSubmit} />;
}
// Organized side effects by category
function useCreateOrder() {
const queryClient = useQueryClient();
const { track } = useAnalytics();
const { user } = useAuth();
return useMutation({
mutationFn: createOrderApi,
onSuccess: (order, variables) => {
// 1. Cache updates
queryClient.invalidateQueries({ queryKey: ['orders'] });
queryClient.invalidateQueries({ queryKey: ['cart'] });
queryClient.setQueryData(['order', order.id], order);
// 2. Analytics (non-critical, wrapped in try-catch)
try {
track('order_created', {
orderId: order.id,
total: order.total,
itemCount: variables.items.length,
userId: user?.id,
});
} catch (e) {
// Analytics failure shouldn't affect mutation
console.warn('Analytics failed:', e);
}
},
onError: (error, variables) => {
// 3. Error tracking
errorReporter.capture(error, {
context: 'order_creation',
itemCount: variables.items.length,
});
},
onSettled: () => {
// 4. Cleanup
queryClient.invalidateQueries({ queryKey: ['inventory'] });
},
});
}
// Safe async side effects
function useDeleteAccount() {
const queryClient = useQueryClient();
const { logout } = useAuth();
return useMutation({
mutationFn: deleteAccountApi,
onSuccess: async () => {
// Critical side effect
queryClient.clear(); // Clear all cached data
// Non-critical async side effects - handle errors
try {
await Promise.all([
clearLocalStorage(),
unsubscribeFromNotifications(),
revokeTokens(),
]);
} catch (e) {
// Log but don't fail the mutation
console.error('Cleanup error:', e);
}
// Navigate last
logout();
},
});
}
// Conditional side effects
function useUpdatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updatePostApi,
onSuccess: (post, variables) => {
// Always update caches
queryClient.invalidateQueries({ queryKey: ['posts'] });
queryClient.setQueryData(['post', post.id], post);
// Conditional side effects based on what changed
if (variables.data.status === 'published' && !post.wasPublished) {
// Newly published - notify followers
notifyFollowers(post.authorId, post.id);
}
if (variables.data.tags !== undefined) {
// Tags changed - update tag caches
queryClient.invalidateQueries({ queryKey: ['tags'] });
}
},
});
}
// Global mutation side effects via MutationCache
const mutationCache = new MutationCache({
onError: (error, variables, context, mutation) => {
// Global error handling for all mutations
if (error instanceof ApiError && error.status === 401) {
// Redirect to login for any 401
logout();
return;
}
// Report to error tracking
errorReporter.capture(error, {
mutation: mutation.options.mutationKey,
});
},
onSuccess: (data, variables, context, mutation) => {
// Global success tracking
analytics.track('mutation_success', {
mutation: mutation.options.mutationKey,
});
},
});
const queryClient = new QueryClient({ mutationCache });Why
1. Maintainability: Separating side effects by type (cache, UI, analytics) makes code easier to understand.
2. Reusability: Hook-level side effects are shared; component-level are specific to that use case.
3. Reliability: Wrapping non-critical side effects prevents them from breaking the mutation flow.
4. Testability: Pure mutationFn is easier to test; side effects can be tested separately.
5. Consistency: Global side effects ensure consistent behavior across the app (error tracking, analytics).
Side effect categories: 1. Cache updates - Critical, in hook 2. Navigation - UI-specific, in component 3. Notifications - UI-specific, in component 4. Analytics - Non-critical, wrap in try-catch 5. Error tracking - Critical, in hook or global 6. Cleanup - In onSettled, runs regardless of outcome
Order of execution:
Hook onSuccess → Component onSuccess → Hook onSettled → Component onSettledMutation Variables
Impact: MEDIUM
Mutation variables are the data passed to mutate() and forwarded to mutationFn and callbacks. Proper typing and structure ensure predictable mutations.
Bad Example
// Anti-pattern: Untyped mutation variables
const mutation = useMutation({
mutationFn: (data: any) => updateUser(data), // No type safety
});
// Anti-pattern: Multiple parameters instead of object
const mutation = useMutation({
mutationFn: (id: string, name: string, email: string) =>
updateUser(id, name, email), // mutate() only accepts one argument
});
// Anti-pattern: Including derived data in variables
const mutation = useMutation({
mutationFn: async (variables) => {
const { userId, formData, timestamp, userAgent } = variables;
// timestamp and userAgent should be added in mutationFn, not passed
return api.update(userId, formData);
},
});
// Anti-pattern: Large objects when only ID is needed
const mutation = useMutation({
mutationFn: (todo: Todo) => deleteTodo(todo), // Only needs todo.id
});
mutation.mutate(entireTodoObject); // Passing more than necessaryGood Example
// Properly typed mutation variables
interface UpdateUserVariables {
id: string;
data: {
name?: string;
email?: string;
avatar?: string;
};
}
const mutation = useMutation<User, ApiError, UpdateUserVariables>({
mutationFn: ({ id, data }) => updateUserApi(id, data),
});
// Usage
mutation.mutate({
id: userId,
data: { name: 'New Name' },
});
// Object parameter for multiple values
interface CreateOrderVariables {
items: OrderItem[];
shippingAddress: Address;
paymentMethod: string;
}
function useCreateOrder() {
return useMutation<Order, ApiError, CreateOrderVariables>({
mutationFn: (variables) => createOrderApi(variables),
onSuccess: (order, variables) => {
// Access variables in callbacks
analytics.track('order_created', {
itemCount: variables.items.length,
total: order.total,
});
},
});
}
// Simple ID-based mutations
function useDeleteTodo() {
return useMutation<void, ApiError, string>({
mutationFn: (todoId) => deleteTodoApi(todoId),
onMutate: (todoId) => {
// todoId is typed as string
console.log(`Deleting todo: ${todoId}`);
},
});
}
// Usage
deleteMutation.mutate(todo.id); // Pass only what's needed
// Variables with validation
interface UploadVariables {
file: File;
folder: string;
}
function useUploadFile() {
return useMutation<UploadResult, ApiError, UploadVariables>({
mutationFn: async ({ file, folder }) => {
// Validation in mutationFn
if (file.size > 10 * 1024 * 1024) {
throw new Error('File too large (max 10MB)');
}
const formData = new FormData();
formData.append('file', file);
formData.append('folder', folder);
return uploadApi(formData);
},
});
}
// Accessing variables throughout mutation lifecycle
function useUpdateSettings() {
const queryClient = useQueryClient();
return useMutation<Settings, ApiError, Partial<Settings>>({
mutationFn: updateSettingsApi,
onMutate: async (newSettings) => {
// newSettings available here
await queryClient.cancelQueries({ queryKey: ['settings'] });
const previous = queryClient.getQueryData<Settings>(['settings']);
queryClient.setQueryData<Settings>(['settings'], (old) => ({
...old!,
...newSettings,
}));
return { previous, newSettings };
},
onSuccess: (result, variables, context) => {
// variables === newSettings passed to mutate()
// context.newSettings also available
console.log('Updated:', Object.keys(variables));
},
onError: (error, variables, context) => {
// Can log which settings failed to update
console.error('Failed to update:', variables);
if (context?.previous) {
queryClient.setQueryData(['settings'], context.previous);
}
},
});
}
// Deriving data inside mutationFn, not variables
function useCreatePost() {
const { user } = useAuth();
return useMutation<Post, ApiError, { title: string; content: string }>({
mutationFn: async (variables) => {
// Add metadata in mutationFn, not in variables
return createPostApi({
...variables,
authorId: user.id,
createdAt: new Date().toISOString(),
});
},
});
}
// Usage - clean variables
createPost.mutate({
title: 'My Post',
content: 'Content here...',
});Why
1. Type Safety: Properly typed variables catch errors at compile time and provide autocomplete.
2. Single Argument: mutate() accepts exactly one argument; use an object for multiple values.
3. Callback Access: Variables are passed to all callbacks, enabling logging, analytics, and rollback.
4. Clean Interface: Pass only the data needed for the mutation, derive metadata inside mutationFn.
5. Predictability: Consistent variable shapes make mutations easier to understand and test.
6. Separation: Keep user input (variables) separate from system-generated data (timestamps, IDs).
Variable flow:
mutate(variables)
↓
onMutate(variables) - can use for optimistic updates
↓
mutationFn(variables) - performs the actual mutation
↓
onSuccess(data, variables, context)
onError(error, variables, context)
↓
onSettled(data, error, variables, context)TypeScript generics order:
useMutation<TData, TError, TVariables, TContext>- TData: Return type of mutationFn
- TError: Error type
- TVariables: Type passed to mutate()
- TContext: Return type of onMutate
Optimistic Updates
Impact: HIGH
Optimistic updates immediately reflect changes in the UI before the server confirms them, providing instant feedback. Proper implementation includes rollback on failure.
Bad Example
// Anti-pattern: Optimistic update without rollback
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: (updatedTodo) => {
queryClient.setQueryData(['todos'], (old: Todo[]) =>
old.map(t => t.id === updatedTodo.id ? updatedTodo : t)
);
// No snapshot for rollback!
},
onError: () => {
toast.error('Update failed');
// Can't rollback - previous data is lost
},
});
// Anti-pattern: Not canceling outgoing queries
const mutation = useMutation({
mutationFn: toggleTodo,
onMutate: async (todoId) => {
// Race condition: refetch might overwrite optimistic update
const previous = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], /* update */);
return { previous };
},
});
// Anti-pattern: Optimistic update for complex operations
const mutation = useMutation({
mutationFn: reorderTodos, // Server calculates new positions
onMutate: (newOrder) => {
// Client might calculate positions differently than server
queryClient.setQueryData(['todos'], sortByOrder(newOrder));
},
});Good Example
// Complete optimistic update pattern
function useToggleTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (todoId: string) => toggleTodoApi(todoId),
onMutate: async (todoId) => {
// 1. Cancel outgoing refetches to prevent race conditions
await queryClient.cancelQueries({ queryKey: ['todos'] });
// 2. Snapshot current state for rollback
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
// 3. Optimistically update the cache
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.map((todo) =>
todo.id === todoId
? { ...todo, completed: !todo.completed }
: todo
)
);
// 4. Return context with snapshot
return { previousTodos };
},
onError: (error, todoId, context) => {
// 5. Rollback on error
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
toast.error('Failed to update todo');
},
onSettled: () => {
// 6. Refetch to ensure server state
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}
// Optimistic update with multiple cache entries
function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateUserApi,
onMutate: async (updatedUser) => {
await queryClient.cancelQueries({ queryKey: ['users'] });
await queryClient.cancelQueries({ queryKey: ['user', updatedUser.id] });
// Snapshot both caches
const previousUsers = queryClient.getQueryData<User[]>(['users']);
const previousUser = queryClient.getQueryData<User>(['user', updatedUser.id]);
// Update list cache
queryClient.setQueryData<User[]>(['users'], (old) =>
old?.map((u) => (u.id === updatedUser.id ? { ...u, ...updatedUser } : u))
);
// Update detail cache
queryClient.setQueryData<User>(['user', updatedUser.id], (old) =>
old ? { ...old, ...updatedUser } : old
);
return { previousUsers, previousUser };
},
onError: (error, variables, context) => {
// Rollback both caches
if (context?.previousUsers) {
queryClient.setQueryData(['users'], context.previousUsers);
}
if (context?.previousUser) {
queryClient.setQueryData(['user', variables.id], context.previousUser);
}
},
onSettled: (data, error, variables) => {
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['user', variables.id] });
},
});
}
// Optimistic add to list
function useAddTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: addTodoApi,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
// Add optimistic todo with temporary ID
const optimisticTodo: Todo = {
id: `temp-${Date.now()}`,
...newTodo,
createdAt: new Date().toISOString(),
isOptimistic: true, // Flag for UI styling
};
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old ? [optimisticTodo, ...old] : [optimisticTodo]
);
return { previousTodos, optimisticTodo };
},
onSuccess: (serverTodo, variables, context) => {
// Replace optimistic todo with server response
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.map((t) =>
t.id === context?.optimisticTodo.id ? serverTodo : t
)
);
},
onError: (error, variables, context) => {
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
},
});
}
// Component showing optimistic state
function TodoItem({ todo }: { todo: Todo }) {
const toggleTodo = useToggleTodo();
return (
<li
className={todo.isOptimistic ? 'opacity-50' : ''}
onClick={() => toggleTodo.mutate(todo.id)}
>
<input
type="checkbox"
checked={todo.completed}
readOnly
/>
{todo.title}
{todo.isOptimistic && <span>(saving...)</span>}
</li>
);
}Why
1. Instant Feedback: Users see changes immediately, making the app feel fast and responsive.
2. Reduced Perceived Latency: No waiting for server round-trip before UI updates.
3. Graceful Failure: Proper rollback ensures data consistency if the server rejects the change.
4. Race Condition Prevention: Canceling queries prevents refetches from overwriting optimistic updates.
5. User Confidence: Visual indicators for optimistic state keep users informed.
When to use optimistic updates:
- Simple, predictable changes (toggles, increments, text edits)
- High-confidence operations (usually succeed)
- Actions where immediate feedback improves UX
When NOT to use:
- Complex server-side calculations
- Operations with high failure rates
- Changes depending on server validation
- Financial or critical data modifications
Paginated Queries
Impact: HIGH
Paginated queries fetch data one page at a time with navigation controls. Unlike infinite queries, they replace content when changing pages rather than accumulating.
Bad Example
// Anti-pattern: Page flicker on navigation
function PaginatedTable() {
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({
queryKey: ['users', page],
queryFn: () => fetchUsers(page),
// No placeholderData - UI flickers on page change
});
if (isLoading) return <Skeleton />; // Shows skeleton on every page change
return <Table data={data} />;
}
// Anti-pattern: Losing previous page data immediately
function UserList() {
const [page, setPage] = useState(1);
const { data } = useQuery({
queryKey: ['users', page],
queryFn: () => fetchUsers(page),
gcTime: 0, // Previous page removed immediately
});
// Going back requires refetch
}
// Anti-pattern: Separate loading state for each page
function BadPagination() {
const [page, setPage] = useState(1);
const [isChangingPage, setIsChangingPage] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ['data', page],
queryFn: () => fetchData(page),
});
const handlePageChange = async (newPage: number) => {
setIsChangingPage(true);
setPage(newPage);
// Manually tracking loading state is redundant
};
}Good Example
// Keep previous data while fetching new page
function PaginatedUserTable() {
const [page, setPage] = useState(1);
const { data, isLoading, isFetching, isPlaceholderData } = useQuery({
queryKey: ['users', page],
queryFn: () => fetchUsers(page),
placeholderData: (previousData) => previousData, // Keep showing old data
staleTime: 5 * 60 * 1000, // 5 minutes
});
return (
<div>
{/* Show loading indicator without hiding content */}
{isFetching && <div className="loading-bar" />}
<Table
data={data?.users}
className={isPlaceholderData ? 'opacity-50' : ''}
/>
<Pagination
currentPage={page}
totalPages={data?.totalPages}
onPageChange={setPage}
disabled={isFetching}
/>
</div>
);
}
// Prefetch adjacent pages for instant navigation
function SmartPaginatedList() {
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const { data, isFetching, isPlaceholderData } = useQuery({
queryKey: ['products', page],
queryFn: () => fetchProducts(page),
placeholderData: (previousData) => previousData,
});
// Prefetch next page on hover or when current page loads
useEffect(() => {
if (data?.hasNextPage) {
queryClient.prefetchQuery({
queryKey: ['products', page + 1],
queryFn: () => fetchProducts(page + 1),
});
}
}, [data, page, queryClient]);
const handleNextPage = () => {
if (!isPlaceholderData && data?.hasNextPage) {
setPage((p) => p + 1);
}
};
const handlePrevPage = () => {
setPage((p) => Math.max(1, p - 1));
};
return (
<div>
<ProductGrid products={data?.products} isLoading={isPlaceholderData} />
<div className="pagination">
<button onClick={handlePrevPage} disabled={page === 1}>
Previous
</button>
<span>Page {page} of {data?.totalPages}</span>
<button
onClick={handleNextPage}
disabled={isPlaceholderData || !data?.hasNextPage}
>
Next
</button>
</div>
</div>
);
}
// Pagination with filters and sorting
interface TableFilters {
search: string;
status: string;
sortBy: string;
sortOrder: 'asc' | 'desc';
}
function FilterablePaginatedTable() {
const [page, setPage] = useState(1);
const [filters, setFilters] = useState<TableFilters>({
search: '',
status: 'all',
sortBy: 'createdAt',
sortOrder: 'desc',
});
// Reset to page 1 when filters change
const handleFilterChange = (newFilters: Partial<TableFilters>) => {
setFilters((prev) => ({ ...prev, ...newFilters }));
setPage(1); // Reset pagination
};
const { data, isFetching, isPlaceholderData } = useQuery({
queryKey: ['orders', { page, ...filters }],
queryFn: () => fetchOrders({ page, ...filters }),
placeholderData: (previousData) => previousData,
});
return (
<div>
<FilterBar filters={filters} onChange={handleFilterChange} />
<div className="relative">
{isFetching && (
<div className="absolute inset-0 bg-white/50 flex items-center justify-center">
<Spinner />
</div>
)}
<OrderTable
orders={data?.orders}
sortBy={filters.sortBy}
sortOrder={filters.sortOrder}
onSort={(field) =>
handleFilterChange({
sortBy: field,
sortOrder:
filters.sortBy === field && filters.sortOrder === 'asc'
? 'desc'
: 'asc',
})
}
/>
</div>
<TablePagination
page={page}
totalPages={data?.totalPages}
totalItems={data?.totalItems}
itemsPerPage={data?.itemsPerPage}
onPageChange={setPage}
/>
</div>
);
}
// URL-synced pagination
function URLPaginatedList() {
const [searchParams, setSearchParams] = useSearchParams();
const page = Number(searchParams.get('page')) || 1;
const { data, isFetching } = useQuery({
queryKey: ['articles', page],
queryFn: () => fetchArticles(page),
placeholderData: (previousData) => previousData,
});
const setPage = (newPage: number) => {
setSearchParams({ page: String(newPage) });
};
return (
// ... component using page and setPage
);
}Why
1. No Flicker: placeholderData: previousData keeps content visible during page transitions.
2. Visual Feedback: isPlaceholderData and isFetching enable subtle loading indicators without hiding content.
3. Prefetching: Preloading adjacent pages makes navigation feel instant.
4. URL Sync: Syncing pagination with URL enables bookmarking, sharing, and browser navigation.
5. Filter Reset: Resetting to page 1 when filters change prevents showing empty pages.
6. Cache Efficiency: Each page is cached separately, allowing quick navigation to previously visited pages.
Pagination vs Infinite Queries:
| Aspect | Paginated | Infinite |
|---|---|---|
| UI Pattern | Page numbers, prev/next | Load more, infinite scroll |
| Cache | Separate per page | All pages in one entry |
| Memory | Constant (one page) | Grows with loaded pages |
| Navigation | Any page directly | Sequential only |
| Best for | Tables, admin panels | Feeds, galleries |
Parallel Queries
Impact: HIGH
Parallel queries fetch multiple independent data sources simultaneously, maximizing performance by eliminating waterfall requests.
Bad Example
// Anti-pattern: Sequential fetching with await
async function fetchDashboardData() {
const user = await fetchUser(); // Wait
const posts = await fetchPosts(); // Then wait
const notifications = await fetchNotifications(); // Then wait
return { user, posts, notifications };
}
// Anti-pattern: Single query for unrelated data
const { data } = useQuery({
queryKey: ['dashboard'],
queryFn: async () => ({
user: await fetchUser(),
posts: await fetchPosts(),
notifications: await fetchNotifications(),
}),
// All-or-nothing: if one fails, all fail
// Can't invalidate user without invalidating posts
});
// Anti-pattern: Using state to coordinate queries
function Dashboard() {
const [userData, setUserData] = useState(null);
const [postsData, setPostsData] = useState(null);
useEffect(() => {
fetchUser().then(setUserData);
fetchPosts().then(setPostsData);
}, []);
// No caching, no error handling, no loading states
}Good Example
// Multiple independent useQuery calls run in parallel
function Dashboard() {
const userQuery = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
});
const postsQuery = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
const notificationsQuery = useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
});
// Each query has independent:
// - Loading state
// - Error state
// - Cache entry
// - Refetch timing
const isLoading =
userQuery.isLoading ||
postsQuery.isLoading ||
notificationsQuery.isLoading;
if (isLoading) return <DashboardSkeleton />;
return (
<div>
<UserCard user={userQuery.data} />
<PostList posts={postsQuery.data} />
<Notifications items={notificationsQuery.data} />
</div>
);
}
// useQueries for dynamic parallel queries
function ProductComparison({ productIds }: { productIds: string[] }) {
const productQueries = useQueries({
queries: productIds.map((id) => ({
queryKey: ['product', id],
queryFn: () => fetchProduct(id),
staleTime: 5 * 60 * 1000,
})),
});
const isLoading = productQueries.some((q) => q.isLoading);
const isError = productQueries.some((q) => q.isError);
const products = productQueries.map((q) => q.data).filter(Boolean);
if (isLoading) return <LoadingGrid count={productIds.length} />;
if (isError) return <ErrorMessage />;
return <ComparisonTable products={products} />;
}
// Combining parallel queries with combine option
function UserDashboard({ userId }: { userId: string }) {
const { data, isLoading } = useQueries({
queries: [
{
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
},
{
queryKey: ['user', userId, 'posts'],
queryFn: () => fetchUserPosts(userId),
},
{
queryKey: ['user', userId, 'followers'],
queryFn: () => fetchUserFollowers(userId),
},
],
combine: (results) => ({
data: {
user: results[0].data,
posts: results[1].data,
followers: results[2].data,
},
isLoading: results.some((r) => r.isLoading),
isError: results.some((r) => r.isError),
}),
});
if (isLoading) return <Skeleton />;
return (
<div>
<Profile user={data.user} />
<Posts posts={data.posts} />
<Followers followers={data.followers} />
</div>
);
}
// Parallel queries with different stale times
function SettingsPage() {
// Static reference data - long cache
const countriesQuery = useQuery({
queryKey: ['countries'],
queryFn: fetchCountries,
staleTime: Infinity,
});
// User preferences - moderate cache
const preferencesQuery = useQuery({
queryKey: ['preferences'],
queryFn: fetchPreferences,
staleTime: 5 * 60 * 1000,
});
// Usage stats - fresh data
const statsQuery = useQuery({
queryKey: ['usage-stats'],
queryFn: fetchUsageStats,
staleTime: 0,
});
return (
<Settings
countries={countriesQuery.data}
preferences={preferencesQuery.data}
stats={statsQuery.data}
isLoading={{
countries: countriesQuery.isLoading,
preferences: preferencesQuery.isLoading,
stats: statsQuery.isLoading,
}}
/>
);
}
// Suspense mode for cleaner parallel queries
function SuspenseDashboard() {
// With Suspense, these automatically run in parallel
// and suspend together until all resolve
const { data: user } = useSuspenseQuery({
queryKey: ['user'],
queryFn: fetchUser,
});
const { data: posts } = useSuspenseQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
// No loading checks needed - Suspense handles it
return (
<div>
<UserProfile user={user} />
<PostList posts={posts} />
</div>
);
}
// With React Suspense boundary
function App() {
return (
<Suspense fallback={<DashboardSkeleton />}>
<SuspenseDashboard />
</Suspense>
);
}Why
1. Performance: Parallel requests reduce total loading time compared to sequential waterfalls.
2. Independent Caching: Each query has its own cache entry with separate staleness and invalidation.
3. Granular Loading: Different parts of the UI can show content as it becomes available.
4. Error Isolation: Failure in one query doesn't affect others; components can show partial data.
5. Flexible Invalidation: Queries can be invalidated independently based on user actions.
6. Dynamic Queries: useQueries handles variable numbers of parallel queries cleanly.
Performance comparison:
Sequential (waterfall):
User ─────────> 200ms
Posts ─────────> 200ms
Notifications ─────────> 200ms
Total: ~600ms
Parallel:
User ─────────────────> 200ms
Posts ────────────────> 200ms
Notifications ────────> 200ms
Total: ~200ms (3x faster)When to use each approach:
- Multiple useQuery: Fixed number of known queries
- useQueries: Dynamic/variable number of queries
- useSuspenseQueries: With React Suspense for cleaner code
Placeholder Data for Immediate Display
Impact: MEDIUM
Placeholder data provides temporary data to display while the actual query is loading. Unlike initialData, it doesn't persist to the cache and doesn't affect staleness.
Bad Example
// Anti-pattern: Using initialData when you want temporary display data
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
initialData: { name: 'Loading...', email: '' }, // This goes into cache!
// Cache now contains fake data until refetch
});
// Anti-pattern: Conditional rendering that causes layout shift
function UserProfile({ userId }: { userId: string }) {
const { data, isLoading } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
if (isLoading) {
return <Skeleton />; // Layout might shift when data loads
}
return <ProfileCard user={data} />;
}
// Anti-pattern: placeholderData that doesn't match data shape
const { data } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
placeholderData: [], // Missing expected fields that component might access
});Good Example
// Use placeholderData for immediate display
const { data: user, isPlaceholderData } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
placeholderData: {
id: userId,
name: 'Loading...',
email: '',
avatar: '/placeholder-avatar.png',
},
});
// Show loading state while using placeholder
return (
<div className={isPlaceholderData ? 'opacity-50' : ''}>
<ProfileCard user={user} />
</div>
);
// Use previous data as placeholder when filters change
function ProductList({ category }: { category: string }) {
const { data: products, isPlaceholderData } = useQuery({
queryKey: ['products', category],
queryFn: () => fetchProducts(category),
placeholderData: (previousData) => previousData, // Keep showing old data
});
return (
<div className={isPlaceholderData ? 'loading' : ''}>
<ProductGrid products={products} />
</div>
);
}
// Generate placeholder from query client cache
function UserDetail({ userId }: { userId: string }) {
const queryClient = useQueryClient();
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
placeholderData: () => {
// Look for user in the users list cache
const users = queryClient.getQueryData<User[]>(['users']);
return users?.find(u => u.id === userId);
},
});
return <UserProfile user={user} />;
}
// Placeholder with realistic structure
interface Post {
id: string;
title: string;
content: string;
author: User;
createdAt: string;
}
const createPostPlaceholder = (postId: string): Post => ({
id: postId,
title: '...',
content: '...',
author: {
id: '',
name: 'Loading...',
avatar: '/placeholder.png',
},
createdAt: new Date().toISOString(),
});
function PostDetail({ postId }: { postId: string }) {
const { data: post, isPlaceholderData } = useQuery({
queryKey: ['post', postId],
queryFn: () => fetchPost(postId),
placeholderData: () => createPostPlaceholder(postId),
});
return (
<article aria-busy={isPlaceholderData}>
<h1>{post.title}</h1>
<p>{post.content}</p>
<AuthorBadge author={post.author} />
</article>
);
}
// Conditional placeholder — only use previous data if query is similar
const { data, isPlaceholderData } = useQuery({
queryKey: ['search', searchTerm],
queryFn: () => search(searchTerm),
placeholderData: (previousData, previousQuery) => {
// Only use previous data if it's from a similar query
if (previousQuery?.queryKey[1]?.toString().startsWith(searchTerm[0])) {
return previousData;
}
return undefined;
},
});Why
1. Instant UI: Users see content structure immediately instead of loading spinners, reducing perceived latency.
2. Layout Stability: Placeholder data maintains layout while real data loads, preventing cumulative layout shift.
3. Cache Integrity: Unlike initialData, placeholder data never enters the cache, so stale checks work correctly.
4. Smooth Transitions: Using previous data as placeholder creates smooth transitions when query parameters change.
5. Progressive Enhancement: Components can render optimistically and enhance when real data arrives.
6. Accessibility: The isPlaceholderData flag allows proper ARIA states and visual loading indicators.
Placeholder vs Initial Data:
| Aspect | placeholderData | initialData |
|---|---|---|
| Enters cache | No | Yes |
| Affects staleness | No | Yes (with staleTime) |
| Available on first render | Yes | Yes |
| Triggers refetch | Always | Depends on staleTime |
| Use case | Temporary display | Known good data |
Prefetching Strategies
Impact: MEDIUM
Prefetching loads data before it's needed, eliminating loading states when users navigate to new views. Strategic prefetching dramatically improves perceived performance.
Bad Example
// Anti-pattern: No prefetching - every navigation shows loading
function UserList() {
const { data: users } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
return (
<ul>
{users?.map((user) => (
<Link key={user.id} to={`/users/${user.id}`}>
{user.name}
</Link>
// No prefetch - clicking shows loading spinner
))}
</ul>
);
}
// Anti-pattern: Prefetching everything eagerly
function App() {
const queryClient = useQueryClient();
useEffect(() => {
// Prefetching all possible data on mount - wasteful
queryClient.prefetchQuery({ queryKey: ['users'], queryFn: fetchUsers });
queryClient.prefetchQuery({ queryKey: ['posts'], queryFn: fetchPosts });
queryClient.prefetchQuery({ queryKey: ['products'], queryFn: fetchProducts });
// ... hundreds more
}, []);
}
// Anti-pattern: Prefetch without stale consideration
function ProductCard({ product }: { product: Product }) {
const queryClient = useQueryClient();
const handleHover = () => {
// Always fetches, even if data is fresh in cache
queryClient.prefetchQuery({
queryKey: ['product', product.id],
queryFn: () => fetchProduct(product.id),
});
};
return <Card onMouseEnter={handleHover}>{product.name}</Card>;
}Good Example
// Prefetch on hover with stale time consideration
function UserListItem({ user }: { user: User }) {
const queryClient = useQueryClient();
const handleMouseEnter = () => {
// Only prefetches if data is stale or missing
queryClient.prefetchQuery({
queryKey: ['user', user.id],
queryFn: () => fetchUser(user.id),
staleTime: 5 * 60 * 1000, // Won't refetch if cached and fresh
});
};
return (
<Link
to={`/users/${user.id}`}
onMouseEnter={handleMouseEnter}
>
{user.name}
</Link>
);
}
// Prefetch next page in pagination
function PaginatedList() {
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const { data } = useQuery({
queryKey: ['items', page],
queryFn: () => fetchItems(page),
placeholderData: (previousData) => previousData,
});
// Prefetch next page when current page loads
useEffect(() => {
if (data?.hasNextPage) {
queryClient.prefetchQuery({
queryKey: ['items', page + 1],
queryFn: () => fetchItems(page + 1),
});
}
}, [data, page, queryClient]);
return (
<div>
<ItemList items={data?.items} />
<Pagination
page={page}
hasNext={data?.hasNextPage}
onNext={() => setPage((p) => p + 1)}
onPrev={() => setPage((p) => p - 1)}
/>
</div>
);
}
// Router-based prefetching
function AppRouter() {
const queryClient = useQueryClient();
return (
<Routes>
<Route
path="/dashboard"
element={<Dashboard />}
loader={async () => {
// Prefetch dashboard data during route transition
await Promise.all([
queryClient.prefetchQuery({
queryKey: ['user'],
queryFn: fetchUser,
}),
queryClient.prefetchQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
}),
]);
return null;
}}
/>
<Route
path="/products/:id"
element={<ProductDetail />}
loader={async ({ params }) => {
await queryClient.prefetchQuery({
queryKey: ['product', params.id],
queryFn: () => fetchProduct(params.id!),
});
return null;
}}
/>
</Routes>
);
}
// Prefetch with Intersection Observer (viewport-based)
function LazyPrefetchCard({ productId }: { productId: string }) {
const queryClient = useQueryClient();
const cardRef = useRef<HTMLDivElement>(null);
const prefetched = useRef(false);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !prefetched.current) {
prefetched.current = true;
queryClient.prefetchQuery({
queryKey: ['product', productId],
queryFn: () => fetchProduct(productId),
});
}
},
{ rootMargin: '100px' } // Start prefetching 100px before visible
);
if (cardRef.current) {
observer.observe(cardRef.current);
}
return () => observer.disconnect();
}, [productId, queryClient]);
return (
<div ref={cardRef}>
<Link to={`/products/${productId}`}>View Product</Link>
</div>
);
}
// SSR prefetching for initial page load
// In Next.js or similar
export async function getServerSideProps() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ['initialData'],
queryFn: fetchInitialData,
});
return {
props: {
dehydratedState: dehydrate(queryClient),
},
};
}
// Smart prefetching based on user behavior
function useSmartPrefetch() {
const queryClient = useQueryClient();
const hoverTimerRef = useRef<NodeJS.Timeout>();
const prefetchOnIntent = (queryKey: QueryKey, queryFn: () => Promise<any>) => {
return {
onMouseEnter: () => {
// Delay prefetch to avoid prefetching on quick mouse passes
hoverTimerRef.current = setTimeout(() => {
queryClient.prefetchQuery({ queryKey, queryFn });
}, 100);
},
onMouseLeave: () => {
// Cancel if mouse leaves quickly
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
}
},
onFocus: () => {
// Keyboard navigation - prefetch immediately
queryClient.prefetchQuery({ queryKey, queryFn });
},
};
};
return { prefetchOnIntent };
}
// Usage
function ProductLink({ product }: { product: Product }) {
const { prefetchOnIntent } = useSmartPrefetch();
return (
<Link
to={`/products/${product.id}`}
{...prefetchOnIntent(
['product', product.id],
() => fetchProduct(product.id)
)}
>
{product.name}
</Link>
);
}Why
1. Zero Loading States: Prefetched data is available immediately when the user navigates.
2. Perceived Performance: The app feels instant because data is ready before it's needed.
3. Bandwidth Optimization: Prefetching during idle time utilizes available bandwidth.
4. Stale-Aware: prefetchQuery respects staleTime, avoiding redundant fetches.
5. Background Loading: Prefetches don't block the UI; they load in the background.
6. SSR Integration: Prefetching integrates seamlessly with server-side rendering.
Prefetch strategies by priority: 1. SSR/SSG: Most critical data for initial render 2. Route loaders: Data needed for the destination page 3. Hover/Focus: High-intent user interactions 4. Viewport: Items about to scroll into view 5. Idle: Low-priority data during browser idle time
Key methods:
prefetchQuery: Prefetch into cacheprefetchInfiniteQuery: Prefetch infinite queryensureQueryData: Return cached data or fetch if missing
Query Functions Best Practices
Impact: CRITICAL
Query functions are the data fetching logic passed to useQuery. They should be pure, handle errors properly, and return consistent data shapes.
Bad Example
// Anti-pattern: Inline query function with side effects
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: async () => {
// Side effect in query function
analytics.track('user_fetched');
const response = await fetch(`/api/users/${userId}`);
// Not handling errors properly
return response.json();
},
});
// Anti-pattern: Query function that doesn't throw on error
const { data, isError } = useQuery({
queryKey: ['posts'],
queryFn: async () => {
const response = await fetch('/api/posts');
if (!response.ok) {
// Returning error data instead of throwing
return { error: true, message: 'Failed to fetch' };
}
return response.json();
},
});
// Anti-pattern: Mutating external state in query function
let cache = {};
const { data } = useQuery({
queryKey: ['data'],
queryFn: async () => {
const result = await fetchData();
cache = result; // Mutating external state
return result;
},
});Good Example
// Create reusable, testable query functions
async function fetchUser(userId: string): Promise<User> {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`Failed to fetch user: ${response.statusText}`);
}
return response.json();
}
async function fetchPosts(params: PostsParams): Promise<PostsResponse> {
const searchParams = new URLSearchParams();
if (params.page) searchParams.set('page', String(params.page));
if (params.limit) searchParams.set('limit', String(params.limit));
if (params.category) searchParams.set('category', params.category);
const response = await fetch(`/api/posts?${searchParams}`);
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new ApiError(response.status, error.message || 'Failed to fetch posts');
}
return response.json();
}
// Custom error class for better error handling
class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
this.name = 'ApiError';
}
}
// Usage with clean separation
const { data: user, error } = useQuery({
queryKey: userKeys.detail(userId),
queryFn: () => fetchUser(userId),
});
// With query function context for cancellation
const { data: posts } = useQuery({
queryKey: postKeys.list(params),
queryFn: async ({ signal }) => {
const response = await fetch(`/api/posts`, { signal });
if (!response.ok) throw new Error('Failed to fetch');
return response.json();
},
});
// Handle side effects outside query function
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
useEffect(() => {
if (data) {
analytics.track('user_fetched');
}
}, [data]);Why
1. Testability: Extracted query functions can be unit tested independently of React components.
2. Error Handling: Throwing errors allows React Query to properly track error state and trigger retries.
3. Separation of Concerns: Query functions should only fetch data; side effects belong in useEffect or mutation callbacks.
4. Cancellation Support: Using the signal from query context enables proper request cancellation when queries are invalidated.
5. Reusability: Standalone query functions can be reused across multiple components and even in non-React contexts.
6. Type Safety: Explicitly typed return values ensure consistent data shapes and better TypeScript integration.
7. Debugging: Clear error messages and proper error classes make debugging network issues much easier.