
Tanstack Query Best Practices
- 9.8k installs
- 205 repo stars
- Updated April 3, 2026
- deckardger/tanstack-agent-skills
tanstack-query-best-practices is an agent skill that TanStack Query (React Query) best practices for data fetching, caching, mutations, and server state management. Activate when building data-driven React applica.
About
TanStack Query React Query best practices for data fetching caching mutations and server state management Activate when building data-driven React applications with server state name tanstack-query-best-practices description TanStack Query React Query best practices for data fetching caching mutations and server state management Activate when building data-driven React applications with server state TanStack Query Best Practices Comprehensive guidelines for implementing TanStack Query React Query patterns in React applications These rules optimize data fetching caching mutations and server state synchronization When to Apply Creating new data fetching logic Setting up query configurations Implementing mutations and optimistic updates Configuring caching strategies Integrating with SSR SSG Refactoring existing data fetching code Rule Categories by Priority Priority Category Rules Impact CRITICAL Query Keys 5 rules Prevents cache bugs and data inconsistencies CRITICAL Caching 5 rules Optimizes performance and data freshness HIGH Mutations 6 rules Ensures data integrity and UI consistency HIGH Error Handling 3 rules Prevents poor user experiences MEDIUM Prefetching 4 rules Improves p.
- TanStack Query Best Practices
- Creating new data fetching logic
- Setting up query configurations
- Implementing mutations and optimistic updates
- Configuring caching strategies
Tanstack Query Best Practices by the numbers
- 9,793 all-time installs (skills.sh)
- +223 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #54 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 4, 2026 (Skillselion catalog sync)
tanstack-query-best-practices capabilities & compatibility
- Capabilities
- tanstack query best practices · creating new data fetching logic · setting up query configurations · implementing mutations and optimistic updates · configuring caching strategies
- Use cases
- documentation
What tanstack-query-best-practices says it does
--- name: tanstack-query-best-practices description: TanStack Query (React Query) best practices for data fetching, caching, mutations, and server state management.
Activate when building data-driven React applications with server state.
--- # TanStack Query Best Practices Comprehensive guidelines for implementing TanStack Query (React Query) patterns in React applications.
These rules optimize data fetching, caching, mutations, and server state synchronization.
npx skills add https://github.com/deckardger/tanstack-agent-skills --skill tanstack-query-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 9.8k |
|---|---|
| repo stars | ★ 205 |
| Security audit | 3 / 3 scanners passed |
| Last updated | April 3, 2026 |
| Repository | deckardger/tanstack-agent-skills ↗ |
What problem does tanstack-query-best-practices solve for developers using this skill?
TanStack Query (React Query) best practices for data fetching, caching, mutations, and server state management. Activate when building data-driven React applications with server state.
Who is it for?
Developers who need tanstack-query-best-practices patterns described in the cached skill documentation.
Skip if: Skip when docs are empty or the task is outside the skill's documented scope.
When should I use this skill?
TanStack Query (React Query) best practices for data fetching, caching, mutations, and server state management. Activate when building data-driven React applications with server state.
What you get
Actionable workflows and conventions from SKILL.md for tanstack-query-best-practices.
- Tuned QueryClient configuration
- Per-query gcTime and staleTime settings
By the numbers
- Documents TanStack Query default gcTime of 5 minutes for inactive queries
Files
TanStack Query Best Practices
Comprehensive guidelines for implementing TanStack Query (React Query) patterns in React applications. These rules optimize data fetching, caching, mutations, and server state synchronization.
When to Apply
- Creating new data fetching logic
- Setting up query configurations
- Implementing mutations and optimistic updates
- Configuring caching strategies
- Integrating with SSR/SSG
- Refactoring existing data fetching code
Rule Categories by Priority
| Priority | Category | Rules | Impact |
|---|---|---|---|
| CRITICAL | Query Keys | 5 rules | Prevents cache bugs and data inconsistencies |
| CRITICAL | Caching | 5 rules | Optimizes performance and data freshness |
| HIGH | Mutations | 6 rules | Ensures data integrity and UI consistency |
| HIGH | Error Handling | 3 rules | Prevents poor user experiences |
| MEDIUM | Prefetching | 4 rules | Improves perceived performance |
| MEDIUM | Parallel Queries | 2 rules | Enables dynamic parallel fetching |
| MEDIUM | Infinite Queries | 3 rules | Prevents pagination bugs |
| MEDIUM | SSR Integration | 4 rules | Enables proper hydration |
| LOW | Performance | 4 rules | Reduces unnecessary re-renders |
| LOW | Offline Support | 2 rules | Enables offline-first patterns |
Quick Reference
Query Keys (Prefix: qk-)
qk-array-structure— Always use arrays for query keysqk-include-dependencies— Include all variables the query depends onqk-hierarchical-organization— Organize keys hierarchically (entity → id → filters)qk-factory-pattern— Use query key factories for complex applicationsqk-serializable— Ensure all key parts are JSON-serializable
Caching (Prefix: cache-)
cache-stale-time— Set appropriate staleTime based on data volatilitycache-gc-time— Configure gcTime for inactive query retentioncache-defaults— Set sensible defaults at QueryClient levelcache-invalidation— Use targeted invalidation over broad patternscache-placeholder-vs-initial— Understand placeholder vs initial data differences
Mutations (Prefix: mut-)
mut-invalidate-queries— Always invalidate related queries after mutationsmut-optimistic-updates— Implement optimistic updates for responsive UImut-rollback-context— Provide rollback context from onMutatemut-error-handling— Handle mutation errors gracefullymut-loading-states— Use isPending for mutation loading statesmut-mutation-state— Use useMutationState for cross-component tracking
Error Handling (Prefix: err-)
err-error-boundaries— Use error boundaries with useQueryErrorResetBoundaryerr-retry-config— Configure retry logic appropriatelyerr-fallback-data— Provide fallback data when appropriate
Prefetching (Prefix: pf-)
pf-intent-prefetch— Prefetch on user intent (hover, focus)pf-route-prefetch— Prefetch data during route transitionspf-stale-time-config— Set staleTime when prefetchingpf-ensure-query-data— Use ensureQueryData for conditional prefetching
Infinite Queries (Prefix: inf-)
inf-page-params— Always provide getNextPageParaminf-loading-guards— Check isFetchingNextPage before fetching moreinf-max-pages— Consider maxPages for large datasets
SSR Integration (Prefix: ssr-)
ssr-dehydration— Use dehydrate/hydrate pattern for SSRssr-client-per-request— Create QueryClient per requestssr-stale-time-server— Set higher staleTime on serverssr-hydration-boundary— Wrap with HydrationBoundary
Parallel Queries (Prefix: parallel-)
parallel-use-queries— Use useQueries for dynamic parallel queriesquery-cancellation— Implement query cancellation properly
Performance (Prefix: perf-)
perf-select-transform— Use select to transform/filter dataperf-structural-sharing— Leverage structural sharingperf-notify-change-props— Limit re-renders with notifyOnChangePropsperf-placeholder-data— Use placeholderData for instant UI
Offline Support (Prefix: offline-)
network-mode— Configure network mode for offline supportpersist-queries— Configure query persistence for offline support
How to Use
Each rule file in the rules/ directory contains: 1. Explanation — Why this pattern matters 2. Bad Example — Anti-pattern to avoid 3. Good Example — Recommended implementation 4. Context — When to apply or skip this rule
Full Reference
See individual rule files in rules/ directory for detailed guidance and code examples.
cache-gc-time: Configure gcTime for Inactive Query Retention
Priority: CRITICAL
Explanation
gcTime (garbage collection time, formerly cacheTime) controls how long inactive queries remain in the cache before being garbage collected. Default is 5 minutes. Configure based on your navigation patterns and memory constraints.
Bad Example
// Not considering gcTime for frequently revisited pages
const { data } = useQuery({
queryKey: ['dashboard-stats'],
queryFn: fetchDashboardStats,
// Default gcTime of 5 minutes - might be too short for frequently revisited data
})
// Setting gcTime too high without consideration
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: Infinity, // Never garbage collect - potential memory leak
},
},
})
// Setting gcTime to 0 - cache is immediately removed
const { data } = useQuery({
queryKey: ['user-data'],
queryFn: fetchUserData,
gcTime: 0, // Loses cache benefits entirely
})Good Example
// Longer gcTime for frequently revisited data
const { data } = useQuery({
queryKey: ['dashboard-stats'],
queryFn: fetchDashboardStats,
gcTime: 30 * 60 * 1000, // 30 minutes - user returns to dashboard often
})
// Shorter gcTime for rarely revisited large data
const { data: report } = useQuery({
queryKey: ['detailed-report', reportId],
queryFn: () => fetchReport(reportId),
gcTime: 2 * 60 * 1000, // 2 minutes - large payload, viewed once
})
// Sensible default with query-specific overrides
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 10 * 60 * 1000, // 10 minutes default
},
},
})Understanding gcTime vs staleTime
Query Mount → Data Fresh (staleTime) → Data Stale → Query Unmount → gcTime countdown → Garbage Collected
Timeline example (staleTime: 1min, gcTime: 5min):
0:00 - Query mounts, fetches data
0:00-1:00 - Data is fresh (no background refetch)
1:00+ - Data is stale (background refetch on new mount)
5:00 - User navigates away, query unmounts
5:00-10:00 - Data in cache but inactive (gcTime countdown)
10:00 - Data garbage collected (next mount = full loading state)Recommended gcTime Values
| Scenario | gcTime | Rationale |
|---|---|---|
| Frequently revisited routes | 15 - 30min | Instant navigation |
| Detail pages (viewed once) | 2 - 5min | Memory efficient |
| Large payloads | 1 - 2min | Prevent memory bloat |
| Critical user data | 30min+ | Offline-like experience |
| SSR hydration | >= 2s | Prevent hydration issues |
Context
- gcTime countdown starts when ALL query observers unmount
- Remounting before gcTime expires returns cached data instantly
- Setting gcTime < staleTime is rarely useful
- For SSR, avoid gcTime: 0 (use minimum 2000ms to allow hydration)
- Monitor memory usage in long-running applications
cache-invalidation: Use Targeted Invalidation Over Broad Patterns
Priority: CRITICAL
Explanation
Query invalidation marks cached data as stale, triggering background refetches. Use targeted invalidation to refresh only affected data. Overly broad invalidation causes unnecessary network requests; too narrow invalidation leaves stale data.
Bad Example
// Invalidating everything after a single todo update
const mutation = useMutation({
mutationFn: updateTodo,
onSuccess: () => {
queryClient.invalidateQueries() // Invalidates ENTIRE cache
},
})
// Invalidating too broadly
const mutation = useMutation({
mutationFn: updateTodoStatus,
onSuccess: () => {
// Invalidates all todos including unrelated lists
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
// Missing invalidation of related queries
const mutation = useMutation({
mutationFn: addComment,
onSuccess: () => {
// Only invalidates comment list, misses comment count
queryClient.invalidateQueries({ queryKey: ['comments', postId] })
},
})Good Example
// Targeted invalidation with exact matching
const mutation = useMutation({
mutationFn: updateTodo,
onSuccess: (data, variables) => {
// Invalidate specific todo and related queries
queryClient.invalidateQueries({ queryKey: ['todos', variables.id] })
// Also invalidate lists that might contain this todo
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] })
},
})
// Use exact: true when you only want one specific query
const mutation = useMutation({
mutationFn: updateUserProfile,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['user', 'profile'],
exact: true, // Only this exact key, not ['user', 'profile', 'settings']
})
},
})
// Invalidate multiple related queries
const mutation = useMutation({
mutationFn: addComment,
onSuccess: (data, { postId }) => {
// Invalidate all comment-related queries for this post
queryClient.invalidateQueries({ queryKey: ['posts', postId, 'comments'] })
queryClient.invalidateQueries({ queryKey: ['posts', postId, 'comment-count'] })
// Optionally invalidate the post itself if it shows comment count
queryClient.invalidateQueries({ queryKey: ['posts', postId] })
},
})
// Predicate-based invalidation for complex scenarios
queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === 'todos' &&
query.state.data?.userId === currentUserId,
})Invalidation Patterns
// Prefix matching (default) - invalidates all matching prefixes
queryClient.invalidateQueries({ queryKey: ['todos'] })
// Matches: ['todos'], ['todos', 1], ['todos', { status: 'done' }]
// Exact matching - only the exact key
queryClient.invalidateQueries({ queryKey: ['todos'], exact: true })
// Matches: ['todos'] only
// Predicate matching - custom logic
queryClient.invalidateQueries({
predicate: (query) => query.queryKey.includes('user-generated'),
})
// Refetch type control
queryClient.invalidateQueries({
queryKey: ['todos'],
refetchType: 'active', // Only refetch active queries (default)
// refetchType: 'inactive' - Only inactive
// refetchType: 'all' - Both
// refetchType: 'none' - Mark stale but don't refetch
})Context
- Invalidation only marks queries as stale; refetch happens when query is used
refetchType: 'active'(default) only refetches queries with active observers- Use hierarchical query keys to enable precise invalidation
- Consider
setQueryDatafor optimistic updates instead of invalidation - Always test invalidation patterns to ensure all affected queries are refreshed
cache-placeholder-vs-initial: Understand Placeholder vs Initial Data
Priority: MEDIUM
Explanation
placeholderData and initialData both provide data before the fetch completes, but behave differently. initialData is treated as real cached data, while placeholderData is temporary and doesn't persist to cache. Choose based on whether your fallback data should be cached.
Bad Example
// Using initialData when you don't want it cached
function PostPreview({ postId, previewData }: Props) {
const { data } = useQuery({
queryKey: ['posts', postId],
queryFn: () => fetchPost(postId),
initialData: previewData, // Wrong: this becomes cached "truth"
// If previewData is incomplete, it pollutes the cache
// staleTime applies to this data as if it were fetched
})
}
// Using placeholderData when you want persistence
function UserProfile({ userId }: Props) {
const { data } = useQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
placeholderData: cachedUserFromList, // Wrong: won't persist
// User navigates away and back - placeholder shown again
// No cache entry created until fetch completes
})
}Good Example: placeholderData for Temporary Display
// Show list data while fetching detail
function PostDetail({ postId }: { postId: string }) {
const queryClient = useQueryClient()
const { data, isPlaceholderData } = useQuery({
queryKey: ['posts', postId],
queryFn: () => fetchPost(postId),
placeholderData: () => {
// Use partial data from list cache as placeholder
const posts = queryClient.getQueryData<Post[]>(['posts'])
return posts?.find(p => p.id === postId)
},
})
return (
<article className={isPlaceholderData ? 'opacity-50' : ''}>
<h1>{data?.title}</h1>
{isPlaceholderData ? (
<p>Loading full content...</p>
) : (
<div>{data?.content}</div>
)}
</article>
)
}Good Example: initialData for Known Good Data
// SSR: Data fetched on server should be initial
function PostPage({ serverData }: { serverData: Post }) {
const { data } = useQuery({
queryKey: ['posts', serverData.id],
queryFn: () => fetchPost(serverData.id),
initialData: serverData,
// Specify when this data was fetched for proper stale calculation
initialDataUpdatedAt: serverData.fetchedAt,
})
return <PostContent post={data} />
}
// Pre-seeding cache with complete data
function App() {
const queryClient = useQueryClient()
// If you have complete, authoritative data
useEffect(() => {
queryClient.setQueryData(['config'], completeConfigData)
}, [])
}Good Example: keepPreviousData Pattern
// Keep showing old data while fetching new (pagination, filters)
function ProductList({ page }: { page: number }) {
const { data, isPlaceholderData } = useQuery({
queryKey: ['products', page],
queryFn: () => fetchProducts(page),
placeholderData: keepPreviousData, // Built-in helper
})
return (
<div className={isPlaceholderData ? 'opacity-70' : ''}>
{data?.map(product => (
<ProductCard key={product.id} product={product} />
))}
{isPlaceholderData && <LoadingOverlay />}
</div>
)
}Comparison Table
| Behavior | initialData | placeholderData |
|---|---|---|
| Persisted to cache | Yes | No |
staleTime applies | Yes | No (always fetches) |
isPlaceholderData | false | true |
| Shown to other components | Yes (cached) | No |
| Use case | SSR, complete known data | Preview, previous page |
Affects dataUpdatedAt | Yes (use initialDataUpdatedAt) | No |
Good Example: Combining Both
function PostDetail({ postId, ssrData }: Props) {
const queryClient = useQueryClient()
const { data } = useQuery({
queryKey: ['posts', postId],
queryFn: () => fetchPost(postId),
// If we have SSR data, use as initial (cached)
initialData: ssrData,
initialDataUpdatedAt: ssrData?.fetchedAt,
// If no SSR data, try to use list preview as placeholder
placeholderData: () => {
if (ssrData) return undefined // Already have initial
const posts = queryClient.getQueryData<Post[]>(['posts'])
return posts?.find(p => p.id === postId)
},
})
}Context
placeholderDatacan be a value or function (lazy evaluation)initialDataaffects cache immediately on query creation- Use
initialDataUpdatedAtwithinitialDatafor proper stale calculations keepPreviousDatais a built-in placeholder strategy- Check
isPlaceholderDatato show loading indicators placeholderDatais ideal for "instant" UI while fetching
cache-stale-time: Set Appropriate staleTime Based on Data Volatility
Priority: CRITICAL
Explanation
staleTime determines how long data is considered fresh. The default is 0ms, meaning data is immediately stale and will refetch on every new query mount. Set appropriate staleTime based on how often your data actually changes to reduce unnecessary network requests.
Bad Example
// Default staleTime of 0 - refetches on every component mount
const { data } = useQuery({
queryKey: ['user-profile', userId],
queryFn: () => fetchUserProfile(userId),
// No staleTime set - always considered stale
})
// User profile probably doesn't change every second
// This causes unnecessary API calls on navigation
// Setting same staleTime everywhere regardless of data type
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute for everything - too simple
},
},
})Good Example
// Match staleTime to data volatility
const { data: profile } = useQuery({
queryKey: ['user-profile', userId],
queryFn: () => fetchUserProfile(userId),
staleTime: 5 * 60 * 1000, // 5 minutes - profile rarely changes
})
const { data: notifications } = useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
staleTime: 30 * 1000, // 30 seconds - changes more frequently
})
const { data: stockPrice } = useQuery({
queryKey: ['stock', symbol],
queryFn: () => fetchStockPrice(symbol),
staleTime: 0, // Real-time data - always refetch
})
// Set sensible defaults, override per-query
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute default
},
},
})Recommended staleTime Values
| Data Type | staleTime | Rationale |
|---|---|---|
| Real-time (stocks, live feeds) | 0 | Must always be current |
| Frequently changing (notifications) | 30s - 1min | Balance freshness and requests |
| User-generated content | 1 - 5min | Changes on user action |
| Reference data (categories, config) | 10 - 30min | Rarely changes |
| Static content | Infinity | Never changes |
Context
staleTime: 0(default) triggers background refetch on every mountstaleTime: Infinitynever considers data stale (manual invalidation only)- Stale data is still returned instantly - refetch happens in background
- For SSR, set higher staleTime to avoid immediate client refetch
- Consider using
queryOptionsfactory to centralize staleTime per data type
err-error-boundaries: Use Error Boundaries with useQueryErrorResetBoundary
Priority: HIGH
Explanation
When using Suspense with TanStack Query, errors propagate to error boundaries. Use useQueryErrorResetBoundary to reset query errors when users retry, preventing stuck error states.
Bad Example
// Error boundary without query reset - retry may not work
function ErrorBoundary({ children }: { children: React.ReactNode }) {
return (
<ReactErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => (
<div>
<p>Error: {error.message}</p>
<button onClick={resetErrorBoundary}>Try again</button>
{/* resetErrorBoundary alone doesn't reset query state */}
</div>
)}
>
{children}
</ReactErrorBoundary>
)
}
// Query error persists after retry clickGood Example
import { useQueryErrorResetBoundary } from '@tanstack/react-query'
import { ErrorBoundary } from 'react-error-boundary'
function QueryErrorBoundary({ children }: { children: React.ReactNode }) {
const { reset } = useQueryErrorResetBoundary()
return (
<ErrorBoundary
onReset={reset}
fallbackRender={({ error, resetErrorBoundary }) => (
<div className="error-container">
<h2>Something went wrong</h2>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>
Try again
</button>
</div>
)}
>
{children}
</ErrorBoundary>
)
}
// Usage with Suspense
function App() {
return (
<QueryErrorBoundary>
<Suspense fallback={<Loading />}>
<Posts />
</Suspense>
</QueryErrorBoundary>
)
}
function Posts() {
// useSuspenseQuery throws on error, caught by boundary
const { data } = useSuspenseQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
return <PostList posts={data} />
}Good Example: With TanStack Router
// Route-level error handling
import { createFileRoute } from '@tanstack/react-router'
import { useQueryErrorResetBoundary } from '@tanstack/react-query'
export const Route = createFileRoute('/posts')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(postQueries.list()),
errorComponent: ({ error, reset }) => {
const { reset: resetQuery } = useQueryErrorResetBoundary()
return (
<div>
<p>Failed to load posts: {error.message}</p>
<button
onClick={() => {
resetQuery()
reset()
}}
>
Retry
</button>
</div>
)
},
component: PostsPage,
})Error Boundary Placement Strategy
// Granular error boundaries for isolated failures
function Dashboard() {
return (
<div className="dashboard">
{/* Each section can fail independently */}
<QueryErrorBoundary>
<Suspense fallback={<Skeleton />}>
<RecentActivity />
</Suspense>
</QueryErrorBoundary>
<QueryErrorBoundary>
<Suspense fallback={<Skeleton />}>
<Statistics />
</Suspense>
</QueryErrorBoundary>
<QueryErrorBoundary>
<Suspense fallback={<Skeleton />}>
<Notifications />
</Suspense>
</QueryErrorBoundary>
</div>
)
}Context
useQueryErrorResetBoundaryclears error state for all queries in the boundary- Always pair Suspense queries with error boundaries
- Place boundaries based on failure isolation needs
- Consider inline error handling for non-critical data
- The reset only affects queries that were in error state
inf-page-params: Always Provide getNextPageParam for Infinite Queries
Priority: MEDIUM
Explanation
useInfiniteQuery requires getNextPageParam to determine how to fetch subsequent pages. This function receives the last page's data and must return the next page parameter, or undefined when there are no more pages.
Bad Example
// Missing getNextPageParam - can't load more pages
const { data, fetchNextPage } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => fetchPosts(pageParam),
initialPageParam: 1,
// Missing getNextPageParam - fetchNextPage won't work correctly
})Good Example: Offset-Based Pagination
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => fetchPosts({ page: pageParam, limit: 20 }),
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) => {
// Return next page number, or undefined if no more pages
if (lastPage.length < 20) {
return undefined // No more pages
}
return allPages.length + 1
},
})Good Example: Cursor-Based Pagination
interface PostsResponse {
posts: Post[]
nextCursor: string | null
}
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }): Promise<PostsResponse> =>
fetchPosts({ cursor: pageParam }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
})Good Example: Bi-directional Pagination
const { data, fetchNextPage, fetchPreviousPage, hasNextPage, hasPreviousPage } =
useInfiniteQuery({
queryKey: ['messages', chatId],
queryFn: ({ pageParam }) => fetchMessages({ chatId, cursor: pageParam }),
initialPageParam: { direction: 'initial' } as PageParam,
getNextPageParam: (lastPage) =>
lastPage.hasMore ? { cursor: lastPage.nextCursor, direction: 'next' } : undefined,
getPreviousPageParam: (firstPage) =>
firstPage.hasPrevious
? { cursor: firstPage.prevCursor, direction: 'prev' }
: undefined,
})Good Example: With Total Count
interface PaginatedResponse<T> {
items: T[]
total: number
page: number
pageSize: number
}
const { data, hasNextPage } = useInfiniteQuery({
queryKey: ['products', filters],
queryFn: ({ pageParam }) =>
fetchProducts({ ...filters, page: pageParam, pageSize: 20 }),
initialPageParam: 1,
getNextPageParam: (lastPage) => {
const totalPages = Math.ceil(lastPage.total / lastPage.pageSize)
if (lastPage.page < totalPages) {
return lastPage.page + 1
}
return undefined
},
})Accessing Flattened Data
// data.pages is an array of page responses
// Flatten for easier iteration
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>
)Context
getNextPageParamreturningundefinedsetshasNextPagetofalse- For bi-directional scrolling, also provide
getPreviousPageParam initialPageParamis required and sets the first page parameter- Use
maxPagesoption to limit stored pages for memory management - Consider
selectto transform page structure for component consumption
mut-invalidate-queries: Always Invalidate Related Queries After Mutations
Priority: HIGH
Explanation
After mutations, invalidate all queries whose data might be affected. This ensures the cache stays synchronized with the server. Forgetting to invalidate related queries leads to stale UI data.
Bad Example
// No invalidation - cache remains stale
const createTodo = useMutation({
mutationFn: (newTodo) => api.createTodo(newTodo),
// Missing onSuccess handler - todo list won't show new item
})
// Partial invalidation - misses related queries
const deleteTodo = useMutation({
mutationFn: (todoId) => api.deleteTodo(todoId),
onSuccess: () => {
// Only invalidates list, not summary/counts
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] })
// Missing: ['todos', 'count'], ['todos', 'completed-count'], etc.
},
})Good Example
// Comprehensive invalidation
const createTodo = useMutation({
mutationFn: (newTodo) => api.createTodo(newTodo),
onSuccess: () => {
// Invalidate all todo-related queries
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
// Targeted invalidation with all affected queries
const updateTodo = useMutation({
mutationFn: ({ id, data }) => api.updateTodo(id, data),
onSuccess: (data, { id }) => {
// Specific todo
queryClient.invalidateQueries({ queryKey: ['todos', id] })
// Lists that might contain this todo
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] })
// If todo status changed, invalidate filtered views
queryClient.invalidateQueries({ queryKey: ['todos', 'completed'] })
queryClient.invalidateQueries({ queryKey: ['todos', 'active'] })
},
})
// Cross-entity invalidation
const assignTodoToUser = useMutation({
mutationFn: ({ todoId, userId }) => api.assignTodo(todoId, userId),
onSuccess: (data, { todoId, userId }) => {
// Invalidate the todo
queryClient.invalidateQueries({ queryKey: ['todos', todoId] })
// Invalidate user's assigned todos
queryClient.invalidateQueries({ queryKey: ['users', userId, 'todos'] })
// Invalidate previous assignee's list if available
if (data.previousAssignee) {
queryClient.invalidateQueries({
queryKey: ['users', data.previousAssignee, 'todos'],
})
}
},
})Pattern: Mutation with Variables Access
const mutation = useMutation({
mutationFn: updatePost,
onSuccess: (
data, // Server response
variables, // What you passed to mutate()
context // What onMutate returned
) => {
// Use variables to know which queries to invalidate
queryClient.invalidateQueries({ queryKey: ['posts', variables.id] })
queryClient.invalidateQueries({ queryKey: ['posts', 'list', variables.category] })
},
})Pattern: Invalidate or Update Directly
// Option 1: Invalidate and refetch
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
}
// Option 2: Update cache directly (no network request)
onSuccess: (newTodo) => {
queryClient.setQueryData(['todos'], (old: Todo[]) => [...old, newTodo])
}
// Option 3: Hybrid - update one, invalidate others
onSuccess: (newTodo) => {
// Immediately add to list
queryClient.setQueryData(['todos', 'list'], (old: Todo[]) => [...old, newTodo])
// Invalidate counts/summaries for eventual consistency
queryClient.invalidateQueries({ queryKey: ['todos', 'count'] })
}Context
- Place invalidation in
onSuccessfor successful mutations - Use
onSettledif you want to invalidate regardless of success/failure - Think about all UI surfaces that display related data
- For complex relationships, consider a centralized invalidation helper
- Using hierarchical query keys makes this easier (see
qk-hierarchical-organization)
mut-mutation-state: Use useMutationState for Cross-Component Mutation Tracking
Priority: MEDIUM
Explanation
useMutationState allows you to access mutation state from anywhere in your component tree, not just where useMutation was called. Use it to show loading indicators, display optimistic updates, or track pending mutations across components.
Bad Example
// Prop drilling mutation state
function App() {
const mutation = useMutation({ mutationFn: createPost })
return (
<div>
<Header isPending={mutation.isPending} />
<Sidebar isPending={mutation.isPending} />
<Content mutation={mutation} />
<Footer isPending={mutation.isPending} />
</div>
)
}
// Or using context for every mutation
const MutationContext = createContext<UseMutationResult | null>(null)Good Example
// Define mutation with a key
const useCreatePost = () => useMutation({
mutationKey: ['create-post'],
mutationFn: createPost,
})
// In the component that triggers mutation
function CreatePostButton() {
const mutation = useCreatePost()
return (
<button onClick={() => mutation.mutate(newPost)}>
Create Post
</button>
)
}
// In any other component - track mutation state
function GlobalLoadingIndicator() {
const pendingMutations = useMutationState({
filters: { status: 'pending' },
select: (mutation) => mutation.state.variables,
})
if (pendingMutations.length === 0) return null
return (
<div className="global-loading">
Saving {pendingMutations.length} item(s)...
</div>
)
}Good Example: Optimistic UI in Separate Component
// Mutation defined in form
function TodoForm() {
const createTodo = useMutation({
mutationKey: ['create-todo'],
mutationFn: (todo: NewTodo) => api.createTodo(todo),
})
return <form onSubmit={...}>...</form>
}
// Optimistic display in list (different component)
function TodoList() {
const { data: todos } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
// Get pending todo creations
const pendingTodos = useMutationState({
filters: {
mutationKey: ['create-todo'],
status: 'pending',
},
select: (mutation) => mutation.state.variables as NewTodo,
})
return (
<ul>
{/* Existing todos */}
{todos?.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
{/* Optimistic todos (pending creation) */}
{pendingTodos.map((todo, index) => (
<TodoItem
key={`pending-${index}`}
todo={{ ...todo, id: `temp-${index}` }}
isPending
/>
))}
</ul>
)
}Good Example: Track Specific Mutations
function PostActions({ postId }: { postId: string }) {
// Track if THIS post is being deleted
const isDeletingThisPost = useMutationState({
filters: {
mutationKey: ['delete-post', postId],
status: 'pending',
},
select: () => true,
}).length > 0
// Track if THIS post is being updated
const isUpdatingThisPost = useMutationState({
filters: {
mutationKey: ['update-post', postId],
status: 'pending',
},
select: () => true,
}).length > 0
return (
<div>
<button disabled={isDeletingThisPost || isUpdatingThisPost}>
{isDeletingThisPost ? 'Deleting...' : 'Delete'}
</button>
</div>
)
}Filters Reference
useMutationState({
filters: {
mutationKey: ['key'], // Match mutation key
status: 'pending', // 'idle' | 'pending' | 'success' | 'error'
predicate: (mutation) => bool, // Custom filter function
},
select: (mutation) => {
// Transform each matching mutation
// mutation.state contains: variables, data, error, status, etc.
return mutation.state.variables
},
})Context
- Requires
mutationKeyon mutations you want to track - Returns array of selected values from matching mutations
- Updates reactively as mutations progress
- Use
statusfilter to track pending/success/error states - Enables optimistic UI without prop drilling
- Pairs with
mutationKeyarrays for granular tracking (e.g.,['delete-post', postId])
mut-optimistic-updates: Implement Optimistic Updates for Responsive UI
Priority: HIGH
Explanation
Optimistic updates immediately reflect changes in the UI before the server confirms them, creating a snappy user experience. Implement them for user-initiated mutations where the expected outcome is predictable.
Bad Example
// No optimistic update - UI waits for server response
const mutation = useMutation({
mutationFn: toggleTodoComplete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
// User clicks checkbox, waits 200-500ms for visual feedbackGood Example: Via Cache Manipulation
const mutation = useMutation({
mutationFn: toggleTodoComplete,
onMutate: async (todoId) => {
// 1. Cancel outgoing refetches to prevent overwriting optimistic update
await queryClient.cancelQueries({ queryKey: ['todos'] })
// 2. Snapshot previous value for potential rollback
const previousTodos = queryClient.getQueryData(['todos'])
// 3. Optimistically update the cache
queryClient.setQueryData(['todos'], (old: Todo[]) =>
old.map((todo) =>
todo.id === todoId ? { ...todo, completed: !todo.completed } : todo
)
)
// 4. Return context for rollback
return { previousTodos }
},
onError: (err, todoId, context) => {
// Rollback on error
queryClient.setQueryData(['todos'], context?.previousTodos)
},
onSettled: () => {
// Refetch to ensure consistency regardless of success/failure
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})Good Example: Via UI Variables (Simpler)
// When mutation only affects local UI, use mutation state directly
function TodoItem({ todo }: { todo: Todo }) {
const mutation = useMutation({
mutationFn: toggleTodoComplete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
// Show optimistic state while pending
const displayCompleted = mutation.isPending
? !todo.completed // Optimistic: show toggled state
: todo.completed // Settled: show actual state
return (
<div>
<input
type="checkbox"
checked={displayCompleted}
disabled={mutation.isPending}
onChange={() => mutation.mutate(todo.id)}
/>
<span style={{ opacity: mutation.isPending ? 0.5 : 1 }}>
{todo.title}
</span>
</div>
)
}Good Example: Optimistic Create with Temporary ID
const createTodo = useMutation({
mutationFn: (newTodo: CreateTodoInput) => api.createTodo(newTodo),
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previousTodos = queryClient.getQueryData(['todos'])
// Add with temporary ID
const optimisticTodo = {
id: `temp-${Date.now()}`,
...newTodo,
completed: false,
createdAt: new Date().toISOString(),
}
queryClient.setQueryData(['todos'], (old: Todo[]) => [...old, optimisticTodo])
return { previousTodos, optimisticTodo }
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context?.previousTodos)
},
onSuccess: (data, variables, context) => {
// Replace temp todo with real one
queryClient.setQueryData(['todos'], (old: Todo[]) =>
old.map((todo) =>
todo.id === context?.optimisticTodo.id ? data : todo
)
)
},
})When to Use Each Approach
| Approach | Use When |
|---|---|
| Cache Manipulation | Update appears in multiple places, complex data structures |
| UI Variables | Update only visible in one component, simpler implementation |
Context
- Always provide rollback logic in
onError - Cancel queries before optimistic update to prevent race conditions
- Call
invalidateQueriesinonSettledto sync with server truth - For forms, consider if validation should block optimistic display
- Test error scenarios to verify rollback works correctly
network-mode: Configure Network Mode for Offline Support
Priority: LOW
Explanation
TanStack Query's networkMode controls how queries and mutations behave when there's no network connection. Configure it based on your app's offline requirements: always fetch, pause when offline, or work entirely offline.
Bad Example
// Not considering offline behavior
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
// Default networkMode: 'online'
// Query pauses with no feedback when offline
})
// User goes offline, sees stale data with no indication
// Mutations silently queue with no UI feedbackGood Example: Default Online Mode with Offline UI
// Show clear offline state to users
function TodoList() {
const { data, fetchStatus, status } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
networkMode: 'online', // Default - pauses when offline
})
// fetchStatus: 'fetching' | 'paused' | 'idle'
// 'paused' means waiting for network
return (
<div>
{fetchStatus === 'paused' && (
<Banner>You're offline. Showing cached data.</Banner>
)}
<TodoItems todos={data} />
</div>
)
}Good Example: Always Mode for Offline-First
// App works offline with local data
const { data, error } = useQuery({
queryKey: ['todos'],
queryFn: async () => {
// Try network first
try {
const todos = await fetchTodosFromServer()
await saveToLocalDB(todos) // Sync to local
return todos
} catch (e) {
// Fall back to local data
return getFromLocalDB()
}
},
networkMode: 'always', // Always runs queryFn, even offline
})
// Or set globally
const queryClient = new QueryClient({
defaultOptions: {
queries: {
networkMode: 'always',
},
mutations: {
networkMode: 'always',
},
},
})Good Example: Offline-First Mode
// Only fetch when online, but don't fail when offline
const { data } = useQuery({
queryKey: ['user-preferences'],
queryFn: fetchPreferences,
networkMode: 'offlineFirst',
// Runs queryFn once, then waits for network if it fails
// Good for: data that's useful to attempt offline
})Good Example: Mutation Offline Queue
function TodoApp() {
const queryClient = useQueryClient()
const addTodo = useMutation({
mutationFn: createTodo,
networkMode: 'online', // Pauses when offline
onMutate: async (newTodo) => {
// Optimistic update works offline
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previous = queryClient.getQueryData(['todos'])
queryClient.setQueryData(['todos'], (old: Todo[]) => [...old, newTodo])
return { previous }
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context?.previous)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
// Track paused mutations
const pendingMutations = useMutationState({
filters: { status: 'pending' },
})
const pausedMutations = pendingMutations.filter(
m => m.state.isPaused
)
return (
<div>
{pausedMutations.length > 0 && (
<Banner>
{pausedMutations.length} changes waiting to sync
</Banner>
)}
<TodoList />
</div>
)
}Network Mode Comparison
| Mode | Behavior | Use Case |
|---|---|---|
'online' (default) | Pauses when offline, resumes when online | Most apps, show offline state |
'always' | Always runs queryFn regardless of network | Offline-first apps, local-only data |
'offlineFirst' | Tries once, then waits for network if fails | Best-effort offline |
Good Example: Online Status Detection
import { onlineManager } from '@tanstack/react-query'
// React to online/offline changes
function NetworkStatus() {
const isOnline = useSyncExternalStore(
onlineManager.subscribe,
() => onlineManager.isOnline(),
)
return (
<div className={isOnline ? 'online' : 'offline'}>
{isOnline ? 'Connected' : 'Offline'}
</div>
)
}
// Manually override online detection (for testing)
onlineManager.setOnline(false)Context
- Default
'online'mode is best for most apps fetchStatus: 'paused'indicates waiting for network- Mutations queue automatically and retry when back online
- Use
onlineManagerto detect and control online state - Combine with optimistic updates for seamless offline UX
- Consider service workers for true offline support
parallel-use-queries: Use useQueries for Dynamic Parallel Queries
Priority: MEDIUM
Explanation
When you need to fetch multiple queries in parallel where the number or identity of queries is dynamic (e.g., fetching details for a list of IDs), use useQueries. It handles parallel execution and returns an array of query results.
Bad Example
// Sequential fetching with useEffect - waterfall
function UserProfiles({ userIds }: { userIds: string[] }) {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
async function fetchAll() {
const results = []
for (const id of userIds) {
const user = await fetchUser(id) // Sequential!
results.push(user)
}
setUsers(results)
setLoading(false)
}
fetchAll()
}, [userIds])
// N requests run one after another
}
// Multiple useQuery calls - breaks rules of hooks
function UserProfiles({ userIds }: { userIds: string[] }) {
// Can't call hooks in a loop!
const queries = userIds.map(id => useQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
}))
}Good Example
import { useQueries } from '@tanstack/react-query'
function UserProfiles({ userIds }: { userIds: string[] }) {
const userQueries = useQueries({
queries: userIds.map(id => ({
queryKey: ['users', id],
queryFn: () => fetchUser(id),
staleTime: 5 * 60 * 1000,
})),
})
const isLoading = userQueries.some(q => q.isLoading)
const isError = userQueries.some(q => q.isError)
const users = userQueries.map(q => q.data).filter(Boolean)
if (isLoading) return <Loading />
if (isError) return <Error />
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}Good Example: With Combine Option
function UserProfiles({ userIds }: { userIds: string[] }) {
const { data: users, isPending } = useQueries({
queries: userIds.map(id => ({
queryKey: ['users', id],
queryFn: () => fetchUser(id),
})),
// Combine results into single value
combine: (results) => ({
data: results.map(r => r.data).filter(Boolean),
isPending: results.some(r => r.isPending),
isError: results.some(r => r.isError),
}),
})
if (isPending) return <Loading />
return <UserList users={users} />
}Good Example: Dependent Parallel Queries
function PostsWithAuthors({ postIds }: { postIds: string[] }) {
// First: fetch all posts in parallel
const postQueries = useQueries({
queries: postIds.map(id => ({
queryKey: ['posts', id],
queryFn: () => fetchPost(id),
})),
})
const posts = postQueries.map(q => q.data).filter(Boolean)
const authorIds = [...new Set(posts.map(p => p.authorId))]
// Then: fetch all unique authors in parallel
const authorQueries = useQueries({
queries: authorIds.map(id => ({
queryKey: ['users', id],
queryFn: () => fetchUser(id),
enabled: posts.length > 0, // Wait for posts
})),
})
// Combine data...
}Good Example: With Suspense
import { useSuspenseQueries } from '@tanstack/react-query'
function UserProfiles({ userIds }: { userIds: string[] }) {
const userQueries = useSuspenseQueries({
queries: userIds.map(id => ({
queryKey: ['users', id],
queryFn: () => fetchUser(id),
})),
})
// All data guaranteed - no loading states needed
const users = userQueries.map(q => q.data)
return <UserList users={users} />
}Context
- Queries run in parallel, not sequentially
- Each query is cached independently
- Use
combineto transform results array into single value - Empty queries array is valid (returns empty results)
- Pairs well with
useSuspenseQueriesfor guaranteed data - Individual query options (staleTime, etc.) apply per-query
perf-select-transform: Use Select to Transform and Filter Data
Priority: LOW
Explanation
The select option transforms query data before it reaches your component. Use it for filtering, sorting, or deriving data. Benefits include memoization (re-runs only when data changes) and reduced component re-renders.
Bad Example
// Transforming in component - runs on every render
function CompletedTodos() {
const { data: todos } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
// This filtering runs on every render
const completedTodos = todos?.filter(todo => todo.completed) ?? []
const sortedTodos = [...completedTodos].sort((a, b) =>
new Date(b.completedAt).getTime() - new Date(a.completedAt).getTime()
)
return <TodoList todos={sortedTodos} />
}Good Example
// Using select - runs only when data changes
function CompletedTodos() {
const { data: completedTodos } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (todos) =>
todos
.filter(todo => todo.completed)
.sort((a, b) =>
new Date(b.completedAt).getTime() - new Date(a.completedAt).getTime()
),
})
return <TodoList todos={completedTodos ?? []} />
}Good Example: Selecting Specific Fields
// Derive computed values
function TodoStats() {
const { data: stats } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (todos) => ({
total: todos.length,
completed: todos.filter(t => t.completed).length,
pending: todos.filter(t => !t.completed).length,
completionRate: todos.length
? (todos.filter(t => t.completed).length / todos.length) * 100
: 0,
}),
})
return (
<div>
<span>{stats?.completed} / {stats?.total} completed</span>
<span>({stats?.completionRate.toFixed(1)}%)</span>
</div>
)
}Good Example: Stable Select with useCallback
// When select depends on external values, stabilize with useCallback
function FilteredTodos({ status }: { status: 'all' | 'active' | 'completed' }) {
const selectTodos = useCallback(
(todos: Todo[]) => {
switch (status) {
case 'active':
return todos.filter(t => !t.completed)
case 'completed':
return todos.filter(t => t.completed)
default:
return todos
}
},
[status]
)
const { data: filteredTodos } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: selectTodos,
})
return <TodoList todos={filteredTodos ?? []} />
}Good Example: Picking Single Item from List
// Select single item from cached list
function useTodoById(id: number) {
return useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (todos) => todos.find(todo => todo.id === id),
})
}
// Usage - shares cache with list query
function TodoDetail({ id }: { id: number }) {
const { data: todo } = useTodoById(id)
if (!todo) return <div>Todo not found</div>
return <div>{todo.title}</div>
}When to Use Select
| Scenario | Use Select? |
|---|---|
| Filtering list data | Yes |
| Sorting data | Yes |
| Computing derived values | Yes |
| Picking single item from list | Yes |
| Heavy transformations | Yes (memoized) |
| Simple data pass-through | No |
| Transformation needs external state | Yes, with useCallback |
Context
selectleverages structural sharing - only re-runs when data actually changes- Original query data stays cached; transformation applies to consumer
- Multiple components can use different
selecton the same query - Avoid unstable function references - use
useCallbackwhen needed - For complex transformations, consider useMemo in component instead if readability suffers
persist-queries: Configure Query Persistence for Offline Support
Priority: LOW
Explanation
TanStack Query can persist the cache to storage (localStorage, IndexedDB, AsyncStorage) and restore it on app load. This enables offline support and faster startup by eliminating initial loading states.
Bad Example
// No persistence - always starts fresh
const queryClient = new QueryClient()
function App() {
return (
<QueryClientProvider client={queryClient}>
<MyApp />
</QueryClientProvider>
)
}
// User refreshes page:
// 1. Empty cache
// 2. Loading spinners everywhere
// 3. Refetch all data
// Poor offline experienceGood Example: Basic Persistence with localStorage
import { QueryClient } from '@tanstack/react-query'
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24, // 24 hours - keep cache longer for persistence
staleTime: 1000 * 60 * 5, // 5 minutes
},
},
})
const persister = createSyncStoragePersister({
storage: window.localStorage,
key: 'REACT_QUERY_CACHE',
})
function App() {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
maxAge: 1000 * 60 * 60 * 24, // 24 hours max
}}
>
<MyApp />
</PersistQueryClientProvider>
)
}Good Example: Async Persistence with IndexedDB
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'
import { get, set, del } from 'idb-keyval'
const persister = createAsyncStoragePersister({
storage: {
getItem: async (key) => await get(key),
setItem: async (key, value) => await set(key, value),
removeItem: async (key) => await del(key),
},
key: 'REACT_QUERY_CACHE',
})
function App() {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
buster: APP_VERSION, // Bust cache on app updates
}}
>
<MyApp />
</PersistQueryClientProvider>
)
}Good Example: Selective Persistence
import { persistQueryClient } from '@tanstack/react-query-persist-client'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24,
},
},
})
// Only persist certain queries
persistQueryClient({
queryClient,
persister,
dehydrateOptions: {
shouldDehydrateQuery: (query) => {
// Don't persist user-specific sensitive data
if (query.queryKey[0] === 'user-session') return false
// Don't persist real-time data
if (query.queryKey[0] === 'notifications') return false
// Don't persist failed queries
if (query.state.status !== 'success') return false
// Persist everything else
return true
},
},
})Good Example: React Native with AsyncStorage
import AsyncStorage from '@react-native-async-storage/async-storage'
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'
const persister = createAsyncStoragePersister({
storage: AsyncStorage,
key: 'app-query-cache',
})
// Usage is the same as webGood Example: Handling Restoration Loading
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
function App() {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister }}
onSuccess={() => {
// Cache restored successfully
console.log('Cache restored')
}}
>
{/* Show loading while restoring */}
<PersistQueryClientProvider.Consumer>
{({ isRestoring }) =>
isRestoring ? <SplashScreen /> : <MainApp />
}
</PersistQueryClientProvider.Consumer>
</PersistQueryClientProvider>
)
}
// Or use the hook
function MainApp() {
const { isRestoring } = usePersistQueryClientRestore()
if (isRestoring) return <SplashScreen />
return <App />
}Persistence Configuration
| Option | Purpose |
|---|---|
maxAge | Maximum cache age before considered invalid |
buster | String to invalidate cache (use app version) |
dehydrateOptions.shouldDehydrateQuery | Filter which queries to persist |
hydrateOptions.shouldHydrate | Filter which queries to restore |
Context
- Requires
@tanstack/react-query-persist-clientpackage - Set
gcTimehigher than default (5 min) for persistence to be useful - Use
busteroption to invalidate cache on app updates - Don't persist sensitive data or real-time data
- IndexedDB is better than localStorage for large caches
- Restored data is still subject to staleTime checks
- Works well with
networkMode: 'offlineFirst'
pf-intent-prefetch: Prefetch on User Intent (Hover, Focus)
Priority: MEDIUM
Explanation
Prefetch data when users show intent to navigate (hover, focus) rather than waiting for click. This eliminates perceived loading time for likely next actions.
Bad Example
// No prefetching - data fetches on click
function PostList({ posts }: { posts: Post[] }) {
return (
<ul>
{posts.map(post => (
<li key={post.id}>
<Link to={`/posts/${post.id}`}>
{post.title}
</Link>
{/* User clicks, waits for data to load */}
</li>
))}
</ul>
)
}Good Example
import { useQueryClient } from '@tanstack/react-query'
import { postQueries } from '@/lib/queries'
function PostList({ posts }: { posts: Post[] }) {
const queryClient = useQueryClient()
const handlePrefetch = (postId: number) => {
queryClient.prefetchQuery({
...postQueries.detail(postId),
staleTime: 60 * 1000, // Consider fresh for 1 minute
})
}
return (
<ul>
{posts.map(post => (
<li key={post.id}>
<Link
to={`/posts/${post.id}`}
onMouseEnter={() => handlePrefetch(post.id)}
onFocus={() => handlePrefetch(post.id)}
>
{post.title}
</Link>
</li>
))}
</ul>
)
}Good Example: With TanStack Router
import { Link } from '@tanstack/react-router'
// TanStack Router has built-in prefetching
function PostList({ posts }: { posts: Post[] }) {
return (
<ul>
{posts.map(post => (
<li key={post.id}>
<Link
to="/posts/$postId"
params={{ postId: post.id }}
preload="intent" // Prefetch on hover/focus
>
{post.title}
</Link>
</li>
))}
</ul>
)
}
// Or set as router default
const router = createRouter({
routeTree,
defaultPreload: 'intent',
defaultPreloadDelay: 100, // Wait 100ms before prefetching
})Good Example: Prefetch with Delay
function PostLink({ post }: { post: Post }) {
const queryClient = useQueryClient()
const timeoutRef = useRef<NodeJS.Timeout>()
const handleMouseEnter = () => {
// Delay prefetch to avoid unnecessary requests on quick mouse movements
timeoutRef.current = setTimeout(() => {
queryClient.prefetchQuery(postQueries.detail(post.id))
}, 100)
}
const handleMouseLeave = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
return (
<Link
to={`/posts/${post.id}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{post.title}
</Link>
)
}Prefetch Triggers
| Trigger | When to Use |
|---|---|
onMouseEnter | Desktop, links/buttons user will likely click |
onFocus | Keyboard navigation, accessibility |
onTouchStart | Mobile, before navigation |
| Component mount | Likely next pages, wizard steps |
| Intersection Observer | Below-fold content |
Context
- Set appropriate
staleTimewhen prefetching to avoid immediate refetch - Consider mobile where hover isn't available
- Don't prefetch everything - focus on likely paths
- Prefetched data uses
gcTimefor retention - Watch network tab to verify prefetch timing
qk-array-structure: Always Use Arrays for Query Keys
Priority: CRITICAL
Explanation
Query keys must always be arrays at the top level. This enables proper caching, invalidation matching, and query deduplication. Using non-array keys will cause unexpected behavior and cache misses.
Bad Example
// Never use strings or non-array types as query keys
const { data } = useQuery({
queryKey: 'todos', // Wrong: string instead of array
queryFn: fetchTodos,
})
const { data: user } = useQuery({
queryKey: { id: 1, type: 'user' }, // Wrong: object instead of array
queryFn: fetchUser,
})Good Example
// Always use arrays for query keys
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
const { data: user } = useQuery({
queryKey: ['user', 1],
queryFn: () => fetchUser(1),
})
// Complex keys with objects inside arrays are fine
const { data: filteredTodos } = useQuery({
queryKey: ['todos', { status: 'done', page: 1 }],
queryFn: () => fetchTodos({ status: 'done', page: 1 }),
})Context
- Always applicable when defining query keys
- Arrays enable prefix-based invalidation (e.g.,
invalidateQueries({ queryKey: ['todos'] })matches all todo queries) - Object property order inside arrays doesn't matter for matching
- Array element order does matter:
['todos', 1]!==['1', 'todos']
qk-factory-pattern: Use Query Key Factories for Complex Applications
Priority: CRITICAL
Explanation
For applications with many queries, centralize query key definitions in factory functions. This ensures consistency, enables autocomplete, prevents typos, and makes refactoring safer. Query key factories are the recommended pattern for production applications.
Bad Example
// Scattered, inconsistent key definitions across files
// file: components/TodoList.tsx
const { data } = useQuery({
queryKey: ['todos', 'list'],
queryFn: fetchTodos,
})
// file: components/TodoDetail.tsx
const { data } = useQuery({
queryKey: ['todo', id], // Inconsistent: 'todo' vs 'todos'
queryFn: () => fetchTodo(id),
})
// file: components/TodoComments.tsx
const { data } = useQuery({
queryKey: ['todoComments', todoId], // Different naming convention
queryFn: () => fetchComments(todoId),
})
// Invalidation is error-prone
queryClient.invalidateQueries({ queryKey: ['todos'] }) // Misses 'todo' and 'todoComments'Good Example
// file: lib/query-keys.ts
export const todoKeys = {
all: ['todos'] as const,
lists: () => [...todoKeys.all, 'list'] as const,
list: (filters: TodoFilters) => [...todoKeys.lists(), filters] as const,
details: () => [...todoKeys.all, 'detail'] as const,
detail: (id: number) => [...todoKeys.details(), id] as const,
comments: (id: number) => [...todoKeys.detail(id), 'comments'] as const,
}
export const userKeys = {
all: ['users'] as const,
detail: (id: string) => [...userKeys.all, id] as const,
posts: (id: string) => [...userKeys.detail(id), 'posts'] as const,
}
// file: components/TodoList.tsx
import { todoKeys } from '@/lib/query-keys'
const { data } = useQuery({
queryKey: todoKeys.list({ status: 'active' }),
queryFn: () => fetchTodos({ status: 'active' }),
})
// file: components/TodoDetail.tsx
const { data } = useQuery({
queryKey: todoKeys.detail(id),
queryFn: () => fetchTodo(id),
})
// Invalidation is type-safe and predictable
queryClient.invalidateQueries({ queryKey: todoKeys.all }) // Invalidates everything
queryClient.invalidateQueries({ queryKey: todoKeys.detail(5) }) // Specific todo + commentsQuery Options Factory Pattern
// Even better: combine with queryOptions for full type safety
import { queryOptions } from '@tanstack/react-query'
export const todoQueries = {
all: () => queryOptions({
queryKey: todoKeys.all,
queryFn: fetchAllTodos,
}),
detail: (id: number) => queryOptions({
queryKey: todoKeys.detail(id),
queryFn: () => fetchTodo(id),
staleTime: 5 * 60 * 1000,
}),
}
// Usage
const { data } = useQuery(todoQueries.detail(5))
await queryClient.prefetchQuery(todoQueries.detail(5))Context
- Essential for applications with 10+ different query types
- Enables IDE autocomplete and typo prevention
- Makes invalidation patterns discoverable
- Pairs well with
queryOptionsfor full type inference - Consider the
@lukemorales/query-key-factorypackage for standardized implementation
qk-hierarchical-organization: Organize Keys Hierarchically
Priority: CRITICAL
Explanation
Structure query keys from general to specific: entity type first, then ID, then modifiers/filters. This enables efficient invalidation at any level of specificity and creates predictable cache organization.
Bad Example
// Flat, inconsistent key structures
const { data: todos } = useQuery({
queryKey: ['all-todos-list'],
queryFn: fetchTodos,
})
const { data: todo } = useQuery({
queryKey: ['single-todo-5'],
queryFn: () => fetchTodo(5),
})
const { data: comments } = useQuery({
queryKey: ['todo-5-comments'],
queryFn: () => fetchTodoComments(5),
})
// Can't easily invalidate all todo-related queriesGood Example
// Hierarchical: entity → id → sub-resource → filters
const { data: todos } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
const { data: todo } = useQuery({
queryKey: ['todos', 5],
queryFn: () => fetchTodo(5),
})
const { data: comments } = useQuery({
queryKey: ['todos', 5, 'comments'],
queryFn: () => fetchTodoComments(5),
})
const { data: filteredTodos } = useQuery({
queryKey: ['todos', { status: 'done', page: 1 }],
queryFn: () => fetchTodos({ status: 'done', page: 1 }),
})
// Now we can invalidate at any level:
queryClient.invalidateQueries({ queryKey: ['todos'] }) // All todos
queryClient.invalidateQueries({ queryKey: ['todos', 5] }) // Todo 5 and its sub-resources
queryClient.invalidateQueries({ queryKey: ['todos', 5, 'comments'] }) // Just commentsRecommended Hierarchy Pattern
['entity'] // List
['entity', id] // Single item
['entity', id, 'sub-resource'] // Related data
['entity', { filters }] // Filtered list
['entity', id, 'sub-resource', { filters }] // Filtered sub-resourceContext
- Essential for applications with related data
- Enables efficient cache management
- Works with prefix-based invalidation
- Consider using query key factories (see
qk-factory-pattern) for consistency
qk-include-dependencies: Include All Variables the Query Depends On
Priority: CRITICAL
Explanation
If your query function depends on a variable, that variable must be included in the query key. This ensures independent caching per variable combination and automatic refetching when dependencies change. Missing dependencies cause stale data bugs and cache collisions.
Bad Example
function UserPosts({ userId }: { userId: string }) {
// Missing userId in query key - all users share the same cache!
const { data } = useQuery({
queryKey: ['posts'],
queryFn: () => fetchPostsByUser(userId),
})
return <PostList posts={data} />
}
function FilteredTodos({ status, page }: { status: string; page: number }) {
// Missing filter parameters - won't refetch when filters change
const { data } = useQuery({
queryKey: ['todos'],
queryFn: () => fetchTodos({ status, page }),
})
return <TodoList todos={data} />
}Good Example
function UserPosts({ userId }: { userId: string }) {
// userId included - each user has their own cache entry
const { data } = useQuery({
queryKey: ['posts', userId],
queryFn: () => fetchPostsByUser(userId),
})
return <PostList posts={data} />
}
function FilteredTodos({ status, page }: { status: string; page: number }) {
// All dependencies included - refetches when any change
const { data } = useQuery({
queryKey: ['todos', { status, page }],
queryFn: () => fetchTodos({ status, page }),
})
return <TodoList todos={data} />
}Context
- This is arguably the most important query key rule
- Applies whenever query function uses external variables
- Prevents subtle bugs where different contexts share cached data
- Works in conjunction with staleTime - even with long staleTime, changing keys triggers new fetches
qk-serializable: Ensure All Key Parts Are JSON-Serializable
Priority: CRITICAL
Explanation
Query keys are hashed using JSON serialization for cache lookups. Non-serializable values (functions, class instances, symbols, circular references) break caching and cause unexpected behavior. All parts of your query key must be JSON-serializable.
Bad Example
// Functions are not serializable
const { data } = useQuery({
queryKey: ['todos', () => 'active'], // Wrong: function in key
queryFn: fetchTodos,
})
// Class instances lose their prototype
class Filter {
constructor(public status: string) {}
isActive() { return this.status === 'active' }
}
const filter = new Filter('active')
const { data: todos } = useQuery({
queryKey: ['todos', filter], // Wrong: class instance
queryFn: () => fetchTodos(filter),
})
// Dates are technically serializable but become strings
const { data: events } = useQuery({
queryKey: ['events', new Date()], // Problematic: new Date() each render
queryFn: () => fetchEvents(date),
})
// Symbols are not serializable
const { data: settings } = useQuery({
queryKey: ['settings', Symbol('user')], // Wrong: symbol
queryFn: fetchSettings,
})Good Example
// Use primitive values and plain objects
const { data } = useQuery({
queryKey: ['todos', 'active'],
queryFn: fetchTodos,
})
// Plain objects are fine
const filters = { status: 'active', priority: 'high' }
const { data: todos } = useQuery({
queryKey: ['todos', filters],
queryFn: () => fetchTodos(filters),
})
// For dates, use stable string representations
const dateKey = date.toISOString().split('T')[0] // '2024-01-15'
const { data: events } = useQuery({
queryKey: ['events', dateKey],
queryFn: () => fetchEvents(date),
})
// Arrays of primitives work correctly
const { data: users } = useQuery({
queryKey: ['users', { ids: [1, 2, 3] }],
queryFn: () => fetchUsers([1, 2, 3]),
})Serializable Types
Safe to use:
- Strings, numbers, booleans, null
- Plain objects (no prototype methods)
- Arrays of serializable values
- undefined (stripped but handled)
Avoid:
- Functions
- Class instances
- Symbols
- Date objects (use ISO strings instead)
- Map/Set (use arrays/objects instead)
- Circular references
Context
- TanStack Query uses deterministic JSON hashing
- Object property order doesn't matter:
{ a: 1, b: 2 }equals{ b: 2, a: 1 } - Keys with
undefinedproperties are normalized:{ a: 1, b: undefined }equals{ a: 1 } - Test serialization:
JSON.stringify(queryKey)should work without errors
query-cancellation: Implement Query Cancellation Properly
Priority: MEDIUM
Explanation
TanStack Query provides an AbortSignal to cancel in-flight requests when queries become stale or components unmount. Pass this signal to your fetch calls to prevent memory leaks and wasted bandwidth.
Bad Example
// Not using abort signal - requests complete even when unnecessary
const { data } = useQuery({
queryKey: ['search', searchTerm],
queryFn: async () => {
// User types fast: "a", "ab", "abc"
// Three requests fire, all complete, wasting bandwidth
const response = await fetch(`/api/search?q=${searchTerm}`)
return response.json()
},
})
// Component unmounts but request keeps running
function UserProfile({ userId }: { userId: string }) {
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: async () => {
const response = await fetch(`/api/users/${userId}`)
return response.json() // Completes even if user navigated away
},
})
}Good Example: Using AbortSignal with Fetch
const { data } = useQuery({
queryKey: ['search', searchTerm],
queryFn: async ({ signal }) => {
const response = await fetch(`/api/search?q=${searchTerm}`, {
signal, // Pass abort signal to fetch
})
return response.json()
},
})
// Now when user types "a", "ab", "abc" quickly:
// - "a" request is cancelled when "ab" starts
// - "ab" request is cancelled when "abc" starts
// - Only "abc" completesGood Example: With Axios
import axios from 'axios'
const { data } = useQuery({
queryKey: ['users', userId],
queryFn: async ({ signal }) => {
const response = await axios.get(`/api/users/${userId}`, {
signal, // Axios supports AbortSignal
})
return response.data
},
})Good Example: Manual Cancellation
function SearchResults() {
const queryClient = useQueryClient()
const [searchTerm, setSearchTerm] = useState('')
const { data } = useQuery({
queryKey: ['search', searchTerm],
queryFn: async ({ signal }) => {
const response = await fetch(`/api/search?q=${searchTerm}`, { signal })
return response.json()
},
enabled: searchTerm.length > 0,
})
// Cancel all search queries manually
const handleClear = () => {
queryClient.cancelQueries({ queryKey: ['search'] })
setSearchTerm('')
}
return (
<div>
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<button onClick={handleClear}>Clear</button>
<Results data={data} />
</div>
)
}Good Example: In Mutations (Before Optimistic Update)
const updateTodo = useMutation({
mutationFn: (todo: Todo) => api.updateTodo(todo),
onMutate: async (newTodo) => {
// Cancel outgoing queries to prevent overwriting optimistic update
await queryClient.cancelQueries({ queryKey: ['todos'] })
await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] })
// Proceed with optimistic update...
const previousTodos = queryClient.getQueryData(['todos'])
queryClient.setQueryData(['todos'], (old) => /* ... */)
return { previousTodos }
},
})Good Example: Custom Cancellable Promise
// For non-fetch APIs that need custom cancellation
const { data } = useQuery({
queryKey: ['expensive-computation', params],
queryFn: ({ signal }) => {
return new Promise((resolve, reject) => {
// Check if already cancelled
if (signal.aborted) {
reject(new DOMException('Aborted', 'AbortError'))
return
}
const worker = new Worker('computation.js')
worker.postMessage(params)
worker.onmessage = (e) => resolve(e.data)
worker.onerror = (e) => reject(e)
// Listen for cancellation
signal.addEventListener('abort', () => {
worker.terminate()
reject(new DOMException('Aborted', 'AbortError'))
})
})
},
})When Queries Are Cancelled
| Scenario | Cancelled? |
|---|---|
| Query key changes | Yes |
| Component unmounts | Yes |
queryClient.cancelQueries() called | Yes |
| Refetch triggered | Previous request cancelled |
enabled becomes false | Yes |
Context
- Always pass
signalto fetch/axios for automatic cancellation - Cancelled queries don't trigger
onError- they're silently dropped - Use
queryClient.cancelQueries()before optimistic updates - AbortError is thrown when cancelled - handle if needed
- Cancellation prevents wasted bandwidth and race conditions
- Essential for search-as-you-type and fast navigation patterns
ssr-dehydration: Use Dehydrate/Hydrate Pattern for SSR
Priority: MEDIUM
Explanation
For server-side rendering, prefetch queries on the server, dehydrate the cache to a serializable format, send it to the client, and hydrate on the client. This prevents content flash and duplicate requests.
Bad Example
// No SSR data passing - client refetches everything
// server-side
export async function getServerSideProps() {
const data = await fetchPosts()
return { props: { posts: data } } // Bypasses React Query cache
}
// client-side
function PostsPage({ posts }: { posts: Post[] }) {
// This doesn't benefit from the server fetch
const { data } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
// Will refetch on client, causing flash
})
return <PostList posts={data ?? posts} /> // Awkward fallback pattern
}Good Example: Next.js App Router
// app/posts/page.tsx
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query'
import { postQueries } from '@/lib/queries'
export default async function PostsPage() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery(postQueries.list())
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostList />
</HydrationBoundary>
)
}
// components/PostList.tsx
'use client'
import { useSuspenseQuery } from '@tanstack/react-query'
import { postQueries } from '@/lib/queries'
export function PostList() {
const { data: posts } = useSuspenseQuery(postQueries.list())
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}Good Example: TanStack Start/Router
// routes/posts.tsx
import { createFileRoute } from '@tanstack/react-router'
import { postQueries } from '@/lib/queries'
export const Route = createFileRoute('/posts')({
loader: async ({ context: { queryClient } }) => {
// Prefetch in route loader
await queryClient.ensureQueryData(postQueries.list())
},
component: PostsPage,
})
function PostsPage() {
const { data: posts } = useSuspenseQuery(postQueries.list())
return <PostList posts={posts} />
}Good Example: Manual SSR Setup
// server.tsx
import { dehydrate, QueryClient } from '@tanstack/react-query'
import { renderToString } from 'react-dom/server'
export async function render(url: string) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // Prevent immediate client refetch
},
},
})
// Prefetch required data
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
const dehydratedState = dehydrate(queryClient)
const html = renderToString(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
)
// Serialize safely - JSON.stringify is XSS vulnerable
const serializedState = serialize(dehydratedState)
return `
<html>
<body>
<div id="app">${html}</div>
<script>window.__DEHYDRATED_STATE__ = ${serializedState}</script>
</body>
</html>
`
}
// client.tsx
import { hydrate, QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
hydrate(queryClient, window.__DEHYDRATED_STATE__)
hydrateRoot(
document.getElementById('app'),
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
)Context
- Create new QueryClient per request to prevent data sharing between users
- Set
staleTime > 0on server to prevent immediate client refetch - Use a safe serializer (not JSON.stringify) to prevent XSS
- Failed queries aren't dehydrated by default; use
shouldDehydrateQueryto override HydrationBoundarycan be nested for route-level prefetching
Related skills
FAQ
What does tanstack-query-best-practices do?
TanStack Query (React Query) best practices for data fetching, caching, mutations, and server state management. Activate when building data-driven React applications with server state.
When should I use tanstack-query-best-practices?
TanStack Query (React Query) best practices for data fetching, caching, mutations, and server state management. Activate when building data-driven React applications with server state.
Is tanstack-query-best-practices safe to install?
Review the Security Audits panel on this page before installing in production.