Fix: The dataset on the search page is not displaying the required field error message. (#14041)

### What problem does this PR solve?

Fix: The dataset on the search page is not displaying the required field
error message.

### Type of change

- [x] Bug Fix (non-breaking change which fixes an issue)
This commit is contained in:
balibabu
2026-04-10 18:20:50 +08:00
committed by GitHub
parent a9ca4ea1a1
commit 11c89d87da
24 changed files with 2820 additions and 26 deletions

View File

@@ -0,0 +1,114 @@
---
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.

View File

@@ -0,0 +1,93 @@
# 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

View File

@@ -0,0 +1,116 @@
# 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

View File

@@ -0,0 +1,156 @@
# 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

View File

@@ -0,0 +1,80 @@
# 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

View File

@@ -0,0 +1,150 @@
# 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

View File

@@ -0,0 +1,132 @@
# 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

View File

@@ -0,0 +1,118 @@
# 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`)

View File

@@ -0,0 +1,169 @@
# 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]`)

View File

@@ -0,0 +1,137 @@
# 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

View File

@@ -0,0 +1,179 @@
# 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

View File

@@ -0,0 +1,152 @@
# 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

View File

@@ -0,0 +1,144 @@
# 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

View File

@@ -0,0 +1,194 @@
# 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'`

View File

@@ -0,0 +1,143 @@
# 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

View File

@@ -0,0 +1,50 @@
# 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']`

View File

@@ -0,0 +1,102 @@
# 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

View File

@@ -0,0 +1,76 @@
# 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

View File

@@ -0,0 +1,62 @@
# 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

View File

@@ -0,0 +1,93 @@
# 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

View File

@@ -0,0 +1,171 @@
# 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

View File

@@ -0,0 +1,158 @@
# 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

10
web/skills-lock.json Normal file
View File

@@ -0,0 +1,10 @@
{
"version": 1,
"skills": {
"tanstack-query-best-practices": {
"source": "deckardger/tanstack-agent-skills",
"sourceType": "github",
"computedHash": "addf4358803d7746f7fe0475a3370d835775217e00dd5fc7bbd8a7d6c53d81e5"
}
}
}

View File

@@ -7,7 +7,7 @@ import { useMemo } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { RAGFlowAvatar } from './ragflow-avatar';
import { FormControl, FormField, FormItem, FormLabel } from './ui/form';
import { RAGFlowFormItem } from './ragflow-form';
import { MultiSelect } from './ui/multi-select';
function buildQueryVariableOptionsByShowVariable(showVariable?: boolean) {
@@ -74,7 +74,6 @@ export function KnowledgeBaseFormField({
name?: string;
required?: boolean;
}) {
const form = useFormContext();
const { t } = useTranslation();
const { datasetOptions } = useDisableDifferenceEmbeddingDataset(name);
@@ -113,31 +112,27 @@ export function KnowledgeBaseFormField({
}, [knowledgeOptions, nextOptions, showVariable, t]);
return (
<FormField
control={form.control}
<RAGFlowFormItem
name={name}
render={({ field }) => (
<FormItem>
<FormLabel tooltip={t('chat.knowledgeBasesTip')} required={required}>
{t('chat.knowledgeBases')}
</FormLabel>
<FormControl>
<MultiSelect
data-testid="chat-datasets-combobox"
options={options}
onValueChange={field.onChange}
placeholder={t('chat.knowledgeBasesPlaceholder')}
variant="inverted"
maxCount={100}
defaultValue={field.value}
showSelectAll={false}
popoverTestId="datasets-options"
optionTestIdPrefix="datasets"
{...field}
/>
</FormControl>
</FormItem>
tooltip={t('chat.knowledgeBasesTip')}
required={required}
label={t('chat.knowledgeBases')}
>
{(field) => (
<MultiSelect
data-testid="chat-datasets-combobox"
options={options}
onValueChange={field.onChange}
placeholder={t('chat.knowledgeBasesPlaceholder')}
variant="inverted"
maxCount={100}
defaultValue={field.value}
showSelectAll={false}
popoverTestId="datasets-options"
optionTestIdPrefix="datasets"
{...field}
/>
)}
/>
</RAGFlowFormItem>
);
}