mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-06-29 15:31:05 +08:00
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:
114
web/.agents/skills/tanstack-query-best-practices/SKILL.md
Normal file
114
web/.agents/skills/tanstack-query-best-practices/SKILL.md
Normal 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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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`)
|
||||
@@ -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]`)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'`
|
||||
@@ -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
|
||||
@@ -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']`
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
10
web/skills-lock.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"tanstack-query-best-practices": {
|
||||
"source": "deckardger/tanstack-agent-skills",
|
||||
"sourceType": "github",
|
||||
"computedHash": "addf4358803d7746f7fe0475a3370d835775217e00dd5fc7bbd8a7d6c53d81e5"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user