rules/cache-defaults.md
# cache-defaults: Set Sensible Defaults at QueryClient Level
## Priority: CRITICAL
## Explanation
Configure sensible default options at the `QueryClient` level to avoid repeating the same configuration across every query. The built-in defaults (`staleTime: 0`, `gcTime: 5 minutes`, `retry: 3`) are aggressive — override them based on your application's data patterns.
## Bad Example
```tsx
// No defaults configured — every query must set its own options
const queryClient = new QueryClient()
// Repetitive per-query configuration
const { data: profile } = useQuery({
queryKey: ['profile'],
queryFn: fetchProfile,
staleTime: 60 * 1000,
retry: 2,
})
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: fetchSettings,
staleTime: 60 * 1000,
retry: 2,
})
```
## Good Example
```tsx
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute — prevents unnecessary refetches
gcTime: 10 * 60 * 1000, // 10 minutes — retains inactive cache longer
retry: 2, // Fewer retries than default 3
refetchOnWindowFocus: false, // Disable if not needed
},
mutations: {
retry: 0, // Mutations should not retry by default
},
},
})
// Only override where specific queries differ
const { data: stockPrice } = useQuery({
queryKey: ['stock', symbol],
queryFn: () => fetchStockPrice(symbol),
staleTime: 0, // Override: real-time data
refetchInterval: 5000, // Poll every 5 seconds
})
const { data: categories } = useQuery({
queryKey: ['categories'],
queryFn: fetchCategories,
staleTime: 30 * 60 * 1000, // Override: rarely changes
})
```
## Context
- Default `staleTime: 0` means data is immediately stale — every mount triggers a refetch
- Default `gcTime: 300000` (5 min) is reasonable for most apps
- Default `retry: 3` with exponential backoff may be too aggressive for some APIs
- For SSR apps, set `staleTime` above 0 to avoid double-fetching after hydration
- Use `queryOptions` factory to centralize per-data-type overrides
- Mutation defaults are separate from query defaults
rules/cache-gc-time.md
# 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
```tsx
// 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
```tsx
// 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
rules/cache-invalidation.md
# 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
```tsx
// 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
```tsx
// 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
```tsx
// 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 `setQueryData` for optimistic updates instead of invalidation
- Always test invalidation patterns to ensure all affected queries are refreshed
rules/cache-placeholder-vs-initial.md
# 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
```tsx
// 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
```tsx
// 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
```tsx
// 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
```tsx
// 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
```tsx
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
- `placeholderData` can be a value or function (lazy evaluation)
- `initialData` affects cache immediately on query creation
- Use `initialDataUpdatedAt` with `initialData` for proper stale calculations
- `keepPreviousData` is a built-in placeholder strategy
- Check `isPlaceholderData` to show loading indicators
- `placeholderData` is ideal for "instant" UI while fetching
rules/cache-stale-time.md
# 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
```tsx
// 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
```tsx
// 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 mount
- `staleTime: Infinity` never 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 `queryOptions` factory to centralize staleTime per data type
rules/err-error-boundaries.md
# 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
```tsx
// 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 click
```
## Good Example
```tsx
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
```tsx
// 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
```tsx
// 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
- `useQueryErrorResetBoundary` clears 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
rules/err-fallback-data.md
# err-fallback-data: Provide Fallback Data When Appropriate
## Priority: MEDIUM
## Explanation
Use `placeholderData` or `initialData` to show meaningful content while queries load or recover from errors. Choose based on whether the data should persist in the cache (`initialData`) or serve as temporary UI scaffolding (`placeholderData`).
## Bad Example
```tsx
// Detail page shows blank loading state despite having data from the list
function PostDetail({ postId }: { postId: string }) {
const { data, isPending } = useQuery({
queryKey: ['post', postId],
queryFn: () => fetchPost(postId),
})
if (isPending) return <Skeleton /> // Unnecessary full skeleton
return <Post data={data} />
}
```
## Good Example: placeholderData from Cache
```tsx
function PostDetail({ postId }: { postId: string }) {
const queryClient = useQueryClient()
const { data, isPlaceholderData } = useQuery({
queryKey: ['post', postId],
queryFn: () => fetchPost(postId),
placeholderData: () => {
// Use partial data from the list cache as placeholder
return queryClient
.getQueryData<Post[]>(['posts'])
?.find((post) => post.id === postId)
},
})
return (
<article style={{ opacity: isPlaceholderData ? 0.7 : 1 }}>
<h1>{data?.title}</h1>
{isPlaceholderData ? (
<p>Loading full content...</p>
) : (
<div>{data?.content}</div>
)}
</article>
)
}
```
## Good Example: keepPreviousData for Pagination
```tsx
import { keepPreviousData } from '@tanstack/react-query'
function ProductList({ page }: { page: number }) {
const { data, isPlaceholderData } = useQuery({
queryKey: ['products', page],
queryFn: () => fetchProducts(page),
placeholderData: keepPreviousData, // Show previous page while loading next
})
return (
<div style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>
{data?.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
)
}
```
## placeholderData vs initialData
| Behavior | `initialData` | `placeholderData` |
|----------|---------------|-------------------|
| Persisted to cache | Yes | No |
| `staleTime` applies | Yes | No (always fetches) |
| `isPlaceholderData` | `false` | `true` |
| Visible to other components | Yes | No |
| On fetch failure | Data preserved | Data becomes `undefined` |
| Best for | SSR data, complete known data | Preview data, pagination |
## Context
- `placeholderData` never persists to cache — it's observer-level only
- `isPlaceholderData` flag lets you style placeholder state differently
- `initialData` affects `dataUpdatedAt` — use `initialDataUpdatedAt` to control staleness
- `keepPreviousData` is a built-in helper that preserves the previous query's data
- For SSR, prefer `initialData` or the hydration API over `placeholderData`
- Use `placeholderData` as a function for lazy evaluation from other cache entries
rules/err-retry-config.md
# err-retry-config: Configure Retry Logic Appropriately
## Priority: HIGH
## Explanation
TanStack Query retries failed queries 3 times by default with exponential backoff (1s, 2s, 4s, capped at 30s). This is too aggressive for some APIs and wastes time for errors that won't resolve by retrying (like 404s). Configure retry logic based on error type and context.
## Bad Example
```tsx
// Relies on default retry: 3 for everything
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
// Default: retries 3 times even for 404 Not Found
// User waits ~7 seconds before seeing the error
})
// Infinite retries — never shows error to user
const { data } = useQuery({
queryKey: ['data'],
queryFn: fetchData,
retry: true, // Retries forever
})
```
## Good Example
```tsx
// Skip retries for client errors (4xx), retry server errors (5xx)
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
retry: (failureCount, error) => {
if (error.status === 404) return false // Not found — don't retry
if (error.status === 403) return false // Forbidden — don't retry
if (error.status >= 400 && error.status < 500) return false
return failureCount < 2 // Retry server errors up to 2 times
},
})
```
## Good Example: Global Retry Defaults
```tsx
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
// Never retry client errors
if (error.status >= 400 && error.status < 500) return false
return failureCount < 2
},
retryDelay: (attemptIndex) =>
Math.min(1000 * 2 ** attemptIndex, 30000), // Exponential backoff
},
mutations: {
retry: 0, // Never retry mutations by default
},
},
})
```
## Retry Option Reference
| Value | Behavior |
|-------|----------|
| `false` or `0` | No retries |
| `true` | Infinite retries (use with caution) |
| `3` (number) | Retry up to 3 times |
| `(failureCount, error) => boolean` | Custom logic per error |
## Context
- Default retry is `3` on client, `0` on server (SSR)
- Default `retryDelay` uses exponential backoff: 1s → 2s → 4s → 8s, capped at 30s
- During retries, the error appears in `failureReason`; only after final failure does it move to `error`
- Mutations default to `retry: 0` — they don't retry unless configured
- For offline-first apps, consider `networkMode: 'offlineFirst'` instead of high retry counts
- `retryOnMount: false` prevents retrying failed queries when a new component mounts
rules/inf-loading-guards.md
# inf-loading-guards: Check isFetchingNextPage Before Fetching More
## Priority: HIGH
## Explanation
Always check `isFetching` or `isFetchingNextPage` before calling `fetchNextPage()`. Without this guard, rapid triggers (scroll events, button clicks) can fire multiple concurrent fetches, potentially overwriting background data refreshes and causing data inconsistencies.
## Bad Example
```tsx
function ProjectList() {
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
return (
<div>
{data?.pages.map((page) =>
page.items.map((item) => <ProjectCard key={item.id} item={item} />)
)}
{/* No loading guard — can trigger multiple fetches */}
{hasNextPage && (
<button onClick={() => fetchNextPage()}>Load More</button>
)}
</div>
)
}
// Infinite scroll without guard — fires on every scroll event
<List onEndReached={() => fetchNextPage()} />
```
## Good Example
```tsx
function ProjectList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetching,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
return (
<div>
{data?.pages.map((page, i) => (
<React.Fragment key={i}>
{page.items.map((item) => (
<ProjectCard key={item.id} item={item} />
))}
</React.Fragment>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetching}
>
{isFetchingNextPage
? 'Loading more...'
: hasNextPage
? 'Load More'
: 'Nothing more to load'}
</button>
{/* Background refetch indicator (not pagination) */}
{isFetching && !isFetchingNextPage && <Spinner />}
</div>
)
}
```
## Good Example: Infinite Scroll
```tsx
<FlatList
data={data?.pages.flatMap((page) => page.items)}
onEndReached={() => {
if (hasNextPage && !isFetching) {
fetchNextPage()
}
}}
onEndReachedThreshold={0.5}
ListFooterComponent={isFetchingNextPage ? <Spinner /> : null}
/>
```
## Context
- `isFetchingNextPage` is true only during pagination fetches, not background refetches
- `isFetching` is true during any fetch (pagination or background) — use this for the guard
- `hasNextPage` is `false` when `getNextPageParam` returns `undefined` or `null`
- Disable the trigger element during fetches to prevent duplicate requests
- Distinguish between pagination loading and background refresh in the UI
rules/inf-max-pages.md
# inf-max-pages: Consider maxPages for Large Datasets
## Priority: MEDIUM
## Explanation
By default, `useInfiniteQuery` keeps all fetched pages in memory and refetches all of them on stale data refresh. For large datasets, this causes excessive memory usage and slow refetches. Use the `maxPages` option to cap how many pages are retained in the cache.
## Bad Example
```tsx
// No maxPages — all pages accumulate in memory
const { data } = useInfiniteQuery({
queryKey: ['feed'],
queryFn: fetchFeedPage,
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
// User scrolls through 50 pages → 50 pages in memory
// Background refetch fetches all 50 pages sequentially
})
```
## Good Example
```tsx
const { data, fetchNextPage, fetchPreviousPage } = useInfiniteQuery({
queryKey: ['feed'],
queryFn: fetchFeedPage,
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
getPreviousPageParam: (firstPage) => firstPage.prevCursor,
maxPages: 5, // Only keep 5 pages in cache at a time
})
// When maxPages is reached:
// - Fetching next page drops the first page
// - Fetching previous page drops the last page
// - Background refetch only refetches the retained pages
```
## Good Example: Offset-Based Pagination with maxPages
```tsx
const { data } = useInfiniteQuery({
queryKey: ['products'],
queryFn: ({ pageParam }) => fetchProducts({ page: pageParam, limit: 20 }),
initialPageParam: 1,
getNextPageParam: (lastPage, allPages, lastPageParam) => {
if (lastPage.length === 0) return undefined
return lastPageParam + 1
},
getPreviousPageParam: (firstPage, allPages, firstPageParam) => {
if (firstPageParam <= 1) return undefined
return firstPageParam - 1
},
maxPages: 3, // Keep 3 pages (60 items) in memory
})
```
## Context
- When `maxPages` is set, `getPreviousPageParam` **must** also be defined so evicted pages can be re-fetched
- Pages are evicted in FIFO order — fetching forward drops the earliest page, fetching backward drops the latest
- Background refetches only refetch the retained pages, not all historically fetched pages
- Without `maxPages`, refetching fetches all pages sequentially from the first one
- Choose `maxPages` based on memory constraints and typical scroll depth
- For virtualized lists, `maxPages` pairs well with scroll-based page eviction
rules/inf-page-params.md
# 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
```tsx
// 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
```tsx
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
```tsx
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
```tsx
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
```tsx
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
```tsx
// 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
- `getNextPageParam` returning `undefined` sets `hasNextPage` to `false`
- For bi-directional scrolling, also provide `getPreviousPageParam`
- `initialPageParam` is required and sets the first page parameter
- Use `maxPages` option to limit stored pages for memory management
- Consider `select` to transform page structure for component consumption
rules/mut-error-handling.md
# mut-error-handling: Handle Mutation Errors Gracefully
## Priority: HIGH
## Explanation
Mutations can fail for many reasons — network errors, validation failures, server issues. Always handle mutation errors explicitly using `onError` callbacks, `mutateAsync` with try/catch, or `throwOnError` with error boundaries. Use `reset()` to clear error state for retry flows.
## Bad Example
```tsx
const mutation = useMutation({
mutationFn: createPost,
// No error handling — errors silently stored in mutation.error
// User has no way to know what happened or retry
})
// Fire and forget — no feedback on failure
<button onClick={() => mutation.mutate(newPost)}>Create</button>
```
## Good Example: onError Callback
```tsx
const mutation = useMutation({
mutationFn: createPost,
onError: (error) => {
toast.error(`Failed to create post: ${error.message}`)
},
onSuccess: () => {
toast.success('Post created!')
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})
```
## Good Example: Inline Error Display with reset()
```tsx
function CreatePostForm() {
const mutation = useMutation({ mutationFn: createPost })
return (
<form onSubmit={(e) => {
e.preventDefault()
mutation.mutate(formData)
}}>
{/* ... form fields ... */}
{mutation.isError && (
<div className="error">
<p>Error: {mutation.error.message}</p>
<button type="button" onClick={() => mutation.reset()}>
Dismiss
</button>
</div>
)}
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
)
}
```
## Good Example: mutateAsync with try/catch
```tsx
async function handleSubmit(data: FormData) {
try {
const result = await mutation.mutateAsync(data)
router.navigate({ to: `/posts/${result.id}` })
} catch (error) {
// Error is also stored in mutation.error
// Handle navigation-blocking errors here
}
}
```
## Context
- `onError` on the hook fires for every `mutate` call; `onError` on `mutate()` fires only for the last concurrent call
- Use `mutation.reset()` to clear error/data state back to idle
- `mutateAsync` throws on error — use when you need to block on the result
- `throwOnError: true` propagates errors to the nearest error boundary
- For optimistic updates, combine `onError` with rollback context from `onMutate`
- Mutation `retry` defaults to `0` — mutations don't retry unless configured
rules/mut-invalidate-queries.md
# 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
```tsx
// 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
```tsx
// 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
```tsx
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
```tsx
// 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 `onSuccess` for successful mutations
- Use `onSettled` if 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`)
rules/mut-loading-states.md
# mut-loading-states: Use isPending for Mutation Loading States
## Priority: MEDIUM
## Explanation
In TanStack Query v5, `isLoading` was renamed to `isPending` for mutations. Use `isPending` to disable buttons, show spinners, and prevent double submissions. Use `variables` to render optimistic UI while a mutation is in flight.
## Bad Example
```tsx
// Using v4 API — isLoading was renamed in v5
const mutation = useMutation({ mutationFn: createTodo })
// No loading state — allows double submission
<button onClick={() => mutation.mutate(newTodo)}>
Create
</button>
// Checking status string instead of boolean helpers
if (mutation.status === 'loading') { /* v4 pattern, broken in v5 */ }
```
## Good Example
```tsx
const mutation = useMutation({
mutationFn: createTodo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
<button
onClick={() => mutation.mutate(newTodo)}
disabled={mutation.isPending}
>
{mutation.isPending ? 'Creating...' : 'Create Todo'}
</button>
```
## Good Example: Inline Optimistic Item with variables
```tsx
function TodoList() {
const { data: todos } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
const addTodo = useMutation({
mutationFn: createTodo,
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
})
return (
<ul>
{todos?.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
{addTodo.isPending && (
<li style={{ opacity: 0.5 }}>{addTodo.variables.title}</li>
)}
</ul>
)
}
```
## Mutation Status Reference
| Status | Boolean | Meaning |
|--------|---------|---------|
| `'idle'` | `isIdle` | Mutation has not been called yet |
| `'pending'` | `isPending` | Mutation is currently executing |
| `'error'` | `isError` | Last mutation attempt failed |
| `'success'` | `isSuccess` | Last mutation completed successfully |
## Context
- `isPending` replaced `isLoading` in v5 — check migration guide if upgrading
- `variables` holds the data passed to `mutate()` — available during `isPending` for optimistic UI
- `submittedAt` provides a timestamp for the mutation — useful as a key for optimistic items
- Always disable submit buttons during `isPending` to prevent double submissions
- Use `mutation.reset()` to return to `idle` state after showing success/error
rules/mut-mutation-state.md
# 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
```tsx
// 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
```tsx
// 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
```tsx
// 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
```tsx
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
```tsx
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 `mutationKey` on mutations you want to track
- Returns array of selected values from matching mutations
- Updates reactively as mutations progress
- Use `status` filter to track pending/success/error states
- Enables optimistic UI without prop drilling
- Pairs with `mutationKey` arrays for granular tracking (e.g., `['delete-post', postId]`)
rules/mut-optimistic-updates.md
# 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
```tsx
// 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 feedback
```
## Good Example: Via Cache Manipulation
```tsx
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)
```tsx
// 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
```tsx
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 `invalidateQueries` in `onSettled` to sync with server truth
- For forms, consider if validation should block optimistic display
- Test error scenarios to verify rollback works correctly
rules/mut-rollback-context.md
# mut-rollback-context: Provide Rollback Context from onMutate
## Priority: HIGH
## Explanation
When implementing optimistic updates via cache manipulation, return a snapshot of the previous data from `onMutate`. This value is passed as the third argument to `onError`, `onSuccess`, and `onSettled`, enabling cache rollback on failure.
## Bad Example
```tsx
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
// Optimistically update without saving previous state
queryClient.setQueryData(['todos'], (old: Todo[]) =>
old.map((t) => (t.id === newTodo.id ? { ...t, ...newTodo } : t))
)
// No return — nothing to rollback with
},
onError: (err) => {
// Can't rollback — previous data is lost
// Forced to refetch everything
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
```
## Good Example
```tsx
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
// 1. Cancel outgoing refetches to prevent overwriting optimistic update
await queryClient.cancelQueries({ queryKey: ['todos'] })
// 2. Snapshot the previous value
const previousTodos = queryClient.getQueryData<Todo[]>(['todos'])
// 3. Optimistically update the cache
queryClient.setQueryData(['todos'], (old: Todo[]) =>
old.map((t) => (t.id === newTodo.id ? { ...t, ...newTodo } : t))
)
// 4. Return snapshot as rollback context
return { previousTodos }
},
onError: (err, newTodo, context) => {
// Rollback to the snapshot on failure
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos)
}
},
onSettled: () => {
// Always refetch to sync with server truth
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
```
## Good Example: Multiple Cache Entries
```tsx
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
await queryClient.cancelQueries({ queryKey: ['todo', newTodo.id] })
// Snapshot both the list and detail caches
const previousTodos = queryClient.getQueryData<Todo[]>(['todos'])
const previousTodo = queryClient.getQueryData<Todo>(['todo', newTodo.id])
queryClient.setQueryData(['todos'], (old: Todo[]) =>
old.map((t) => (t.id === newTodo.id ? { ...t, ...newTodo } : t))
)
queryClient.setQueryData(['todo', newTodo.id], (old: Todo) => ({
...old,
...newTodo,
}))
return { previousTodos, previousTodo }
},
onError: (err, newTodo, context) => {
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos)
}
if (context?.previousTodo) {
queryClient.setQueryData(['todo', newTodo.id], context.previousTodo)
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
```
## Context
- The return value from `onMutate` is the third argument (`context`) in `onError`, `onSuccess`, and `onSettled`
- Always cancel related queries before optimistic updates to prevent race conditions
- Always call `invalidateQueries` in `onSettled` to reconcile with the server
- For simple UI-only optimistic updates, use `mutation.variables` and `mutation.isPending` instead — no rollback needed
- Test error scenarios to verify rollback restores correct state
rules/network-mode.md
# 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
```tsx
// 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 feedback
```
## Good Example: Default Online Mode with Offline UI
```tsx
// 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
```tsx
// 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
```tsx
// 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
```tsx
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
```tsx
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 `onlineManager` to detect and control online state
- Combine with optimistic updates for seamless offline UX
- Consider service workers for true offline support
rules/parallel-use-queries.md
# 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
```tsx
// 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
```tsx
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
```tsx
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
```tsx
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
```tsx
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 `combine` to transform results array into single value
- Empty queries array is valid (returns empty results)
- Pairs well with `useSuspenseQueries` for guaranteed data
- Individual query options (staleTime, etc.) apply per-query
rules/perf-notify-change-props.md
# perf-notify-change-props: Limit Re-renders with notifyOnChangeProps
## Priority: LOW
## Explanation
TanStack Query uses JavaScript Proxy to track which properties a component accesses from the query result. It only triggers re-renders when one of those accessed properties changes. This happens automatically, but certain patterns can break it.
## Bad Example
```tsx
// Object rest destructuring — disables tracked properties optimization
const { data, ...rest } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
// `...rest` accesses ALL properties via the Proxy's get trap
// Component re-renders when ANY property changes (isFetching, isStale, etc.)
// Accessing properties you don't need
function TodoList() {
const { data, isFetching, isStale, dataUpdatedAt, status } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
// Only uses `data` in render, but re-renders for isFetching, isStale changes too
return <List items={data} />
}
```
## Good Example
```tsx
// Only destructure what you use — tracking works automatically
function TodoList() {
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
// Only re-renders when `data` changes
return <List items={data} />
}
// When you need isFetching for a spinner, that's fine — it's intentional
function TodoListWithRefresh() {
const { data, isFetching } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
return (
<>
{isFetching && <Spinner />}
<List items={data} />
</>
)
}
```
## Good Example: Manual Override
```tsx
// Explicitly control which properties trigger re-renders
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
notifyOnChangeProps: ['data', 'error'], // Only re-render for data or error changes
})
// Opt out of tracking entirely — re-render on any change
const result = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
notifyOnChangeProps: 'all',
})
```
## Good Example: Prefetch Observer (No Re-renders)
```tsx
// Use notifyOnChangeProps: [] to observe a query without causing re-renders
// Useful for prefetching in a parent component
useQuery({
queryKey: ['comments', postId],
queryFn: () => fetchComments(postId),
notifyOnChangeProps: [], // Never triggers re-render — just populates cache
})
```
## Context
- Tracked properties is the default since v4 — no configuration needed for most cases
- Avoid object rest destructuring (`...rest`) as it breaks the Proxy optimization
- There is an ESLint rule (`no-rest-destructuring`) to catch this pattern
- Tracking only works during render — properties accessed in `useEffect` bodies are not tracked
- Tracking persists across render cycles — once a property is tracked, it stays tracked
- `notifyOnChangeProps: []` is useful for "observer-only" queries that prefetch without rendering
rules/perf-placeholder-data.md
# perf-placeholder-data: Use placeholderData for Instant UI
## Priority: LOW
## Explanation
`placeholderData` provides temporary data to display immediately while the real data loads. Unlike `initialData`, it is not persisted to the cache. Use it to show preview content from other cache entries, keep previous page data during pagination, or display skeleton-like content without a loading state.
## Bad Example
```tsx
// Full loading state for paginated data — content disappears on page change
function ProductList({ page }: { page: number }) {
const { data, isPending } = useQuery({
queryKey: ['products', page],
queryFn: () => fetchProducts(page),
})
if (isPending) return <Skeleton /> // Entire list disappears while loading
return <ProductGrid products={data} />
}
// Detail page shows nothing while loading despite having list data
function UserProfile({ userId }: { userId: string }) {
const { data, isPending } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
if (isPending) return <Skeleton /> // Could show name/avatar from list
return <Profile user={data} />
}
```
## Good Example: keepPreviousData for Pagination
```tsx
import { keepPreviousData } from '@tanstack/react-query'
function ProductList({ page }: { page: number }) {
const { data, isPlaceholderData } = useQuery({
queryKey: ['products', page],
queryFn: () => fetchProducts(page),
placeholderData: keepPreviousData,
})
return (
<div style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>
<ProductGrid products={data} />
<button
onClick={() => setPage((p) => p + 1)}
disabled={isPlaceholderData} // Prevent rapid page flipping
>
Next Page
</button>
</div>
)
}
```
## Good Example: Placeholder from Another Cache Entry
```tsx
function UserProfile({ userId }: { userId: string }) {
const queryClient = useQueryClient()
const { data, isPlaceholderData } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
placeholderData: () => {
// Pull partial data from the users list cache
return queryClient
.getQueryData<User[]>(['users'])
?.find((user) => user.id === userId)
},
})
return (
<div>
<h1>{data?.name}</h1>
{isPlaceholderData ? (
<p>Loading full profile...</p>
) : (
<ProfileDetails user={data} />
)}
</div>
)
}
```
## Context
- `placeholderData` is never persisted to the cache — it's observer-level only
- `isPlaceholderData` is `true` while showing placeholder, transitions to `false` when real data arrives
- Always triggers a background fetch regardless of `staleTime`
- `keepPreviousData` is a built-in helper — import from `@tanstack/react-query`
- Use `isPlaceholderData` to visually indicate partial/stale content (opacity, skeleton overlays)
- For SSR-fetched data, use `initialData` or the hydration pattern instead
rules/perf-select-transform.md
# 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
```tsx
// 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
```tsx
// 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
```tsx
// 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
```tsx
// 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
```tsx
// 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
- `select` leverages structural sharing - only re-runs when data actually changes
- Original query data stays cached; transformation applies to consumer
- Multiple components can use different `select` on the same query
- Avoid unstable function references - use `useCallback` when needed
- For complex transformations, consider useMemo in component instead if readability suffers
rules/perf-structural-sharing.md
# perf-structural-sharing: Leverage Structural Sharing
## Priority: LOW
## Explanation
TanStack Query uses structural sharing to preserve referential identity for unchanged data between refetches. When a background refetch returns data, unchanged portions keep their original object references, preventing unnecessary re-renders in components that depend on stable references.
## Bad Example
```tsx
// Disabling structural sharing unnecessarily
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
structuralSharing: false, // Every refetch creates new references for everything
// Downstream useMemo/useEffect with data deps re-trigger on every refetch
})
// Using non-JSON data types breaks structural sharing silently
const { data } = useQuery({
queryKey: ['dates'],
queryFn: async () => {
const res = await fetch('/api/events')
const events = await res.json()
// Returning Map/Set/Date instances — structural sharing only works with JSON types
return new Map(events.map((e) => [e.id, new Date(e.date)]))
},
})
```
## Good Example
```tsx
// Structural sharing is enabled by default — just works
const { data: todos } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
// structuralSharing: true (default)
// If refetch returns same data, `todos` keeps identical references
})
// Safe to use in dependency arrays
useEffect(() => {
// Only runs when data actually changes, not on every refetch
analytics.track('todos-loaded', { count: todos?.length })
}, [todos])
```
## Good Example: Custom Structural Sharing
```tsx
// For non-JSON types, provide a custom comparator
import { replaceEqualDeep } from '@tanstack/react-query'
const { data } = useQuery({
queryKey: ['events'],
queryFn: fetchEvents,
structuralSharing: (oldData, newData) => {
// Custom comparison that handles your data types
return replaceEqualDeep(oldData, newData)
},
})
```
## Context
- Structural sharing only works with JSON-compatible values (objects, arrays, strings, numbers, booleans, null)
- Map, Set, Date, and class instances are not structurally compared — consider keeping data as plain JSON
- The comparison happens twice when using `select`: once on the raw result, once on the selected output
- Disable with `structuralSharing: false` only when using non-JSON data types or when the comparison cost exceeds the render savings
- This is why TanStack Query can refetch aggressively (window focus, mount) without causing excessive re-renders
rules/persist-queries.md
# 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
```tsx
// 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 experience
```
## Good Example: Basic Persistence with localStorage
```tsx
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
```tsx
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
```tsx
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
```tsx
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 web
```
## Good Example: Handling Restoration Loading
```tsx
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-client` package
- Set `gcTime` higher than default (5 min) for persistence to be useful
- Use `buster` option 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'`
rules/pf-ensure-query-data.md
# pf-ensure-query-data: Use ensureQueryData for Conditional Prefetching
## Priority: MEDIUM
## Explanation
`ensureQueryData` checks the cache before fetching — it only makes a network request if the query doesn't exist in the cache. Unlike `prefetchQuery`, it returns the data and throws on error. Use it in route loaders where you need to guarantee data exists.
## Bad Example
```tsx
// Always fetches, even when cache already has fresh data
loader: async ({ params, context: { queryClient } }) => {
await queryClient.fetchQuery({
queryKey: ['post', params.postId],
queryFn: () => fetchPost(params.postId),
})
}
// prefetchQuery also refetches when staleTime is 0 (the default)
loader: async ({ params, context: { queryClient } }) => {
await queryClient.prefetchQuery({
queryKey: ['post', params.postId],
queryFn: () => fetchPost(params.postId),
})
}
```
## Good Example
```tsx
loader: async ({ params, context: { queryClient } }) => {
// Only fetches if cache is empty — returns cached data otherwise
await queryClient.ensureQueryData({
queryKey: ['post', params.postId],
queryFn: () => fetchPost(params.postId),
})
}
```
## Good Example: With revalidateIfStale
```tsx
loader: async ({ params, context: { queryClient } }) => {
// Returns stale data immediately, triggers background refetch
await queryClient.ensureQueryData({
queryKey: ['post', params.postId],
queryFn: () => fetchPost(params.postId),
revalidateIfStale: true,
})
}
```
## API Comparison
| Method | Returns | On error | Cache hit (fresh) | Cache hit (stale) | Cache miss |
|--------|---------|----------|-------------------|-------------------|------------|
| `prefetchQuery` | `void` | Swallows | Skips fetch | Refetches | Fetches |
| `fetchQuery` | `TData` | Throws | Skips fetch | Refetches | Fetches |
| `ensureQueryData` | `TData` | Throws | Returns cached | Returns cached* | Fetches |
*With `revalidateIfStale: true`, returns stale data immediately and refetches in background.
## Context
- `ensureQueryData` is ideal for route loaders — fast cache hits, only fetches when needed
- Default `revalidateIfStale` is `false` — stale data is returned without refetching
- Use `prefetchQuery` for fire-and-forget prefetching where you don't need the data immediately
- Use `fetchQuery` when you need the data and want to handle errors (e.g., responding with 404)
- `ensureQueryData` internally calls `fetchQuery` on cache miss
- Pair with `queryOptions` factory to share config between loader and component
rules/pf-intent-prefetch.md
# 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
```tsx
// 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
```tsx
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
```tsx
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
```tsx
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 `staleTime` when prefetching to avoid immediate refetch
- Consider mobile where hover isn't available
- Don't prefetch everything - focus on likely paths
- Prefetched data uses `gcTime` for retention
- Watch network tab to verify prefetch timing
rules/pf-route-prefetch.md
# pf-route-prefetch: Prefetch Data During Route Transitions
## Priority: MEDIUM
## Explanation
Prefetch query data in route loaders so it's available instantly when the page renders. Use `ensureQueryData` for critical data that should block navigation, and `prefetchQuery` for non-critical data that can load in the background.
## Bad Example
```tsx
// No prefetching — data only loads after route renders
export const Route = createFileRoute('/posts/$postId')({
component: PostPage,
})
function PostPage() {
const { postId } = Route.useParams()
// Fetch starts only after component mounts — shows loading spinner
const { data, isPending } = useQuery({
queryKey: ['post', postId],
queryFn: () => fetchPost(postId),
})
if (isPending) return <Skeleton />
return <Post data={data} />
}
```
## Good Example
```tsx
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, context: { queryClient } }) => {
// Critical data — block navigation until loaded
await queryClient.ensureQueryData({
queryKey: ['post', params.postId],
queryFn: () => fetchPost(params.postId),
})
// Non-critical — prefetch but don't block navigation
queryClient.prefetchQuery({
queryKey: ['post-comments', params.postId],
queryFn: () => fetchComments(params.postId),
})
},
component: PostPage,
})
function PostPage() {
const { postId } = Route.useParams()
// Data is already in cache — renders instantly
const { data: post } = useSuspenseQuery({
queryKey: ['post', postId],
queryFn: () => fetchPost(postId),
})
// May still be loading, but won't block the page
const { data: comments, isPending } = useQuery({
queryKey: ['post-comments', postId],
queryFn: () => fetchComments(postId),
})
return (
<article>
<PostContent post={post} />
{isPending ? <CommentsSkeleton /> : <Comments data={comments} />}
</article>
)
}
```
## Good Example: Parallel Prefetching
```tsx
loader: async ({ params, context: { queryClient } }) => {
// Prefetch multiple independent queries in parallel
await Promise.all([
queryClient.ensureQueryData({
queryKey: ['post', params.postId],
queryFn: () => fetchPost(params.postId),
}),
queryClient.ensureQueryData({
queryKey: ['author', params.postId],
queryFn: () => fetchAuthor(params.postId),
}),
])
}
```
## Context
- `ensureQueryData` returns cached data if available, only fetches on cache miss
- `prefetchQuery` never throws — failed prefetches silently retry when the component mounts
- Without a `staleTime` above 0, prefetched data is immediately stale and refetches on mount
- Set `staleTime` on the corresponding `useQuery` to prevent double-fetching
- Use `queryOptions` factory to share configuration between loader and component
- For SSR, see the `ssr-dehydration` rule for the full hydration pattern
rules/pf-stale-time-config.md
# pf-stale-time-config: Set staleTime When Prefetching
## Priority: MEDIUM
## Explanation
When prefetching data, you must set a `staleTime` above 0 on the corresponding `useQuery` — otherwise the default `staleTime: 0` makes prefetched data immediately stale, triggering a refetch as soon as the component mounts. This defeats the purpose of prefetching.
## Bad Example
```tsx
// Prefetch in route loader
loader: async ({ context: { queryClient } }) => {
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
}
// Component — no staleTime set
function Posts() {
const { data } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
// staleTime defaults to 0
// Data is immediately stale → refetches on mount
// The prefetch was wasted!
})
}
```
## Good Example: staleTime on useQuery
```tsx
const postsQueryOptions = queryOptions({
queryKey: ['posts'],
queryFn: fetchPosts,
staleTime: 60 * 1000, // 1 minute — prevents refetch after prefetch
})
// Loader uses the same options
loader: async ({ context: { queryClient } }) => {
await queryClient.prefetchQuery(postsQueryOptions)
}
// Component uses the same options — staleTime prevents double-fetch
function Posts() {
const { data } = useQuery(postsQueryOptions)
}
```
## Good Example: Global Default for SSR/Prefetch Apps
```tsx
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // Prevent refetch within 1 minute of prefetch
},
},
})
```
## The Problem Visualized
```
Without staleTime:
Server/Loader: |--prefetch--| ✓ cached
Client mount: |--refetch---| ← wasted request (data was "stale" at 0ms)
With staleTime: 60s:
Server/Loader: |--prefetch--| ✓ cached (fresh for 60s)
Client mount: ✓ uses cache ← no refetch needed
```
## Context
- This is the most common prefetching mistake — always pair prefetch with appropriate `staleTime`
- Use `queryOptions` to share the same config between prefetch and `useQuery`
- `ensureQueryData` returns cached data regardless of staleness (with `revalidateIfStale: false`), but `useQuery` still refetches if stale
- For SSR, set a global default `staleTime` above 0 to avoid double-fetching all queries
- The `staleTime` passed to `prefetchQuery` only controls whether the prefetch itself runs — it does not affect the `useQuery` subscriber
rules/qk-array-structure.md
# 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
```tsx
// 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
```tsx
// 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']`
rules/qk-factory-pattern.md
# 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
```tsx
// 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
```tsx
// 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 + comments
```
## Query Options Factory Pattern
```tsx
// 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 `queryOptions` for full type inference
- Consider the `@lukemorales/query-key-factory` package for standardized implementation
rules/qk-hierarchical-organization.md
# 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
```tsx
// 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 queries
```
## Good Example
```tsx
// 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 comments
```
## Recommended 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-resource
```
## Context
- 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
rules/qk-include-dependencies.md
# 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
```tsx
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
```tsx
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
rules/qk-serializable.md
# 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
```tsx
// 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
```tsx
// 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 `undefined` properties are normalized: `{ a: 1, b: undefined }` equals `{ a: 1 }`
- Test serialization: `JSON.stringify(queryKey)` should work without errors
rules/query-cancellation.md
# 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
```tsx
// 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
```tsx
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" completes
```
## Good Example: With Axios
```tsx
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
```tsx
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)
```tsx
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
```tsx
// 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 `signal` to 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
rules/ssr-client-per-request.md
# ssr-client-per-request: Create QueryClient Per Request
## Priority: CRITICAL
## Explanation
On the server, create a new `QueryClient` for every request. A shared `QueryClient` at module scope leaks cached data between requests and users — this is a security vulnerability and a performance problem.
## Bad Example
```tsx
// NEVER do this — shared across all requests/users
const queryClient = new QueryClient()
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<Page />
</QueryClientProvider>
)
}
// User A's private data is cached and served to User B
```
## Good Example: React State (Pages Router / Remix)
```tsx
export default function App({ Component, pageProps }: AppProps) {
// useState ensures one client per component instance (per request on server)
const [queryClient] = React.useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // Prevent immediate refetch after hydration
},
},
})
)
return (
<QueryClientProvider client={queryClient}>
<HydrationBoundary state={pageProps.dehydratedState}>
<Component {...pageProps} />
</HydrationBoundary>
</QueryClientProvider>
)
}
```
## Good Example: Server Components (App Router)
```tsx
// app/providers.tsx
'use client'
import { isServer, QueryClient, QueryClientProvider } from '@tanstack/react-query'
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
})
}
let browserQueryClient: QueryClient | undefined = undefined
function getQueryClient() {
if (isServer) {
// Server: always create a new client (one per request)
return makeQueryClient()
} else {
// Browser: reuse the same client across renders
if (!browserQueryClient) browserQueryClient = makeQueryClient()
return browserQueryClient
}
}
export default function Providers({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient()
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
```
## Context
- Module-scope `QueryClient` leaks data between users — this is a security issue
- On the server, `gcTime` defaults to `Infinity` — memory is cleaned up when the request ends
- On the browser, reuse a singleton client to persist cache across navigations
- Avoid `useState` for the client when there is no Suspense boundary between the provider and suspending content — React discards state on initial render if it suspends
- Use `isServer` from `@tanstack/react-query` to detect environment
- For server-side loaders (getServerSideProps, Remix loaders), create a `new QueryClient()` per loader call
rules/ssr-dehydration.md
# 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
```tsx
// 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
```tsx
// 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
```tsx
// 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
```tsx
// 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 > 0` on server to prevent immediate client refetch
- Use a safe serializer (not JSON.stringify) to prevent XSS
- Failed queries aren't dehydrated by default; use `shouldDehydrateQuery` to override
- `HydrationBoundary` can be nested for route-level prefetching
rules/ssr-hydration-boundary.md
# ssr-hydration-boundary: Wrap with HydrationBoundary
## Priority: HIGH
## Explanation
Use the `dehydrate` / `HydrationBoundary` pattern to transfer server-prefetched data to the client cache. This is the recommended SSR approach — it properly populates the cache with correct timestamps and works with any data-fetching component in the tree.
## Bad Example
```tsx
// Using initialData prop drilling — fragile and limited
export async function getServerSideProps() {
const posts = await fetchPosts()
return { props: { posts } }
}
function PostsPage({ posts }) {
const { data } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
initialData: posts,
// Problem: no dataUpdatedAt — staleness based on page load, not fetch time
// Problem: requires prop drilling to every component that needs data
})
}
```
## Good Example: Pages Router / Remix
```tsx
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
export async function getServerSideProps() {
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 60 * 1000 } },
})
// Prefetch multiple queries in parallel
await Promise.all([
queryClient.prefetchQuery({ queryKey: ['posts'], queryFn: fetchPosts }),
queryClient.prefetchQuery({ queryKey: ['tags'], queryFn: fetchTags }),
])
return { props: { dehydratedState: dehydrate(queryClient) } }
}
export default function PostsRoute({ dehydratedState }) {
return (
<HydrationBoundary state={dehydratedState}>
<Posts />
</HydrationBoundary>
)
}
function Posts() {
// Data is already in cache — no loading spinner
const { data } = useQuery({ queryKey: ['posts'], queryFn: fetchPosts })
return <PostList posts={data} />
}
```
## Good Example: Server Components (App Router)
```tsx
// app/posts/page.tsx — Server Component
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
export default async function PostsPage() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Posts />
</HydrationBoundary>
)
}
// app/posts/posts.tsx — Client Component
'use client'
export default function Posts() {
const { data } = useQuery({ queryKey: ['posts'], queryFn: fetchPosts })
return <PostList posts={data} />
}
```
## Good Example: Remove Boilerplate in _app.tsx
```tsx
// Wrap at the app level to avoid repeating HydrationBoundary per page
export default function App({ Component, pageProps }: AppProps) {
const [queryClient] = React.useState(() => new QueryClient())
return (
<QueryClientProvider client={queryClient}>
<HydrationBoundary state={pageProps.dehydratedState}>
<Component {...pageProps} />
</HydrationBoundary>
</QueryClientProvider>
)
}
```
## Context
- `dehydrate` only includes successful queries by default — failed queries are excluded
- Multiple `HydrationBoundary` components can be nested (e.g., per Server Component)
- Each can use its own `QueryClient` for prefetching — they all hydrate into the nearest provider
- Treat Server Components as a place to prefetch — don't render query data directly in them
- `dehydrate` serializes the cache — avoid non-JSON-serializable data or use custom serializers
- Avoid `gcTime: 0` on the server — data may be garbage collected before hydration completes (minimum: 2 seconds)
rules/ssr-stale-time-server.md
# ssr-stale-time-server: Set Higher staleTime on Server
## Priority: HIGH
## Explanation
When using SSR, set a default `staleTime` above 0 for the server-side `QueryClient`. The default `staleTime: 0` means data fetched on the server is immediately considered stale, causing every query to refetch as soon as the client hydrates — wasting the server-side prefetch.
## Bad Example
```tsx
// Server-side prefetch
export async function getServerSideProps() {
const queryClient = new QueryClient() // staleTime defaults to 0
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
return { props: { dehydratedState: dehydrate(queryClient) } }
}
// Client — data from server is immediately stale
// Every query refetches on mount, doubling network requests
function Posts() {
const { data } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
// staleTime: 0 → instant refetch after hydration
})
}
```
## Good Example
```tsx
// Set staleTime as a default for the SSR QueryClient
export async function getServerSideProps() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // Data stays fresh for 1 minute
},
},
})
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
return { props: { dehydratedState: dehydrate(queryClient) } }
}
// Client-side QueryClient should match
const [queryClient] = React.useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // Same as server — no refetch within 1 minute
},
},
})
)
```
## Good Example: Shared Config
```tsx
// Shared QueryClient factory for both server and client
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
})
}
// Server: new client per request
// Client: singleton
```
## Context
- Without this, every SSR-prefetched query double-fetches on hydration
- Staleness is measured from `dataUpdatedAt` (when fetched on server) — ensure server clock is accurate
- Set `staleTime` on both server and client `QueryClient` for consistent behavior
- Use `queryOptions` to centralize per-query staleTime overrides
- `staleTime: Infinity` makes data permanently fresh — only manual invalidation triggers refetch
- This applies to all SSR approaches: Pages Router, App Router, Remix, TanStack Start
SKILL.md
---
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 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 keys
- `qk-include-dependencies` — Include all variables the query depends on
- `qk-hierarchical-organization` — Organize keys hierarchically (entity → id → filters)
- `qk-factory-pattern` — Use query key factories for complex applications
- `qk-serializable` — Ensure all key parts are JSON-serializable
### Caching (Prefix: `cache-`)
- `cache-stale-time` — Set appropriate staleTime based on data volatility
- `cache-gc-time` — Configure gcTime for inactive query retention
- `cache-defaults` — Set sensible defaults at QueryClient level
- `cache-invalidation` — Use targeted invalidation over broad patterns
- `cache-placeholder-vs-initial` — Understand placeholder vs initial data differences
### Mutations (Prefix: `mut-`)
- `mut-invalidate-queries` — Always invalidate related queries after mutations
- `mut-optimistic-updates` — Implement optimistic updates for responsive UI
- `mut-rollback-context` — Provide rollback context from onMutate
- `mut-error-handling` — Handle mutation errors gracefully
- `mut-loading-states` — Use isPending for mutation loading states
- `mut-mutation-state` — Use useMutationState for cross-component tracking
### Error Handling (Prefix: `err-`)
- `err-error-boundaries` — Use error boundaries with useQueryErrorResetBoundary
- `err-retry-config` — Configure retry logic appropriately
- `err-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 transitions
- `pf-stale-time-config` — Set staleTime when prefetching
- `pf-ensure-query-data` — Use ensureQueryData for conditional prefetching
### Infinite Queries (Prefix: `inf-`)
- `inf-page-params` — Always provide getNextPageParam
- `inf-loading-guards` — Check isFetchingNextPage before fetching more
- `inf-max-pages` — Consider maxPages for large datasets
### SSR Integration (Prefix: `ssr-`)
- `ssr-dehydration` — Use dehydrate/hydrate pattern for SSR
- `ssr-client-per-request` — Create QueryClient per request
- `ssr-stale-time-server` — Set higher staleTime on server
- `ssr-hydration-boundary` — Wrap with HydrationBoundary
### Parallel Queries (Prefix: `parallel-`)
- `parallel-use-queries` — Use useQueries for dynamic parallel queries
- `query-cancellation` — Implement query cancellation properly
### Performance (Prefix: `perf-`)
- `perf-select-transform` — Use select to transform/filter data
- `perf-structural-sharing` — Leverage structural sharing
- `perf-notify-change-props` — Limit re-renders with notifyOnChangeProps
- `perf-placeholder-data` — Use placeholderData for instant UI
### Offline Support (Prefix: `offline-`)
- `network-mode` — Configure network mode for offline support
- `persist-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.