
Accelint Tanstack Query Best Practices
- 249 installs
- 21 repo stars
- Updated August 4, 2026
- gohypergiant/agent-skills
For development and infrastructure management.
About
accelint-tanstack-query-best-practices is an AI coding tool that enhances development workflows. Builders use it for infrastructure, integration, and platform development within the catalog ecosystem.
- accelint-tanstack-query-best-practices
- Development
Accelint Tanstack Query Best Practices by the numbers
- 249 all-time installs (skills.sh)
- Ranked #1,517 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/gohypergiant/agent-skills --skill accelint-tanstack-query-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 249 |
|---|---|
| repo stars | ★ 21 |
| Last updated | August 4, 2026 |
| Repository | gohypergiant/agent-skills ↗ |
What it does
For development and infrastructure management.
Files
TanStack Query Best Practices
Expert patterns for TanStack Query in modern React applications with Next.js App Router and Server Components.
NEVER Do With TanStack Query
- NEVER use a singleton QueryClient on the server - Creates data leakage between users and race conditions. Each request must get its own isolated QueryClient instance to prevent cached data from one user appearing for another.
- NEVER synchronize query data to useState - Background refetches, invalidations, and optimistic updates all modify the cache. Local state copies become stale immediately, causing "my save didn't work" bugs. Use query data directly or derive with useMemo.
- NEVER put queries inside list item components - Creates N observers for N items, causing O(n) iteration on every cache update. 200 list items calling useQuery creates 200 network requests and 200 observers. Hoist queries to parent components.
- NEVER use unstable query keys - Arrays with non-guaranteed order, temporal queries with Date.now(), or object keys without deterministic serialization create infinite cache entries. Keys must be stable and deterministic.
- NEVER skip enabled guards for dependent queries - Firing queries with undefined parameters creates garbage cache entries like ['tracks', undefined] and wastes network requests before real data arrives.
- NEVER ignore AbortController signals - Without query cancellation support, unmounted components leave in-flight requests running, wasting bandwidth and potentially updating stale cache entries.
- NEVER use optimistic updates for high-stakes or external mutations - Life-critical operations, audit trail systems, and mutations triggered by external events need pessimistic updates to ensure UI matches server state.
- NEVER assume structural sharing is free - For datasets >1000 items updating frequently, structural sharing's O(n) deep equality checks become CPU overhead. Disable with structuralSharing: false for large, frequently-changing data.
- NEVER skip onSettled in optimistic updates - onSettled is your cleanup guarantee even if onError throws. Without it, UI can be left in corrupted state when error handler fails. Always pair onMutate with onSettled for resource cleanup and cache consistency.
- NEVER assume cache invalidation is synchronous - invalidateQueries triggers background refetches which can race with optimistic updates. Use cancelQueries in onMutate to prevent background refetches from overwriting your optimistic changes before the mutation completes.
- NEVER use setQueryData without structural comparison - Directly setting cache data bypasses structural sharing and breaks referential equality optimizations. Wrap in updater function to preserve references for unchanged portions:
setQueryData(key, (old) => ({ ...old, changed: value }))instead ofsetQueryData(key, newValue). - NEVER forget to handle hydration mismatches - Server-rendered data may differ from client expectations (timestamps, user-specific data, randomized content). Use suppressHydrationWarning on containers or ensure deterministic server/client rendering with stable timestamps and consistent data sources.
Before Using TanStack Query, Ask
State Classification
- Is this server state or client state? TanStack Query manages server state (API data, database records, external system state). UI state (modals, themes, form drafts) belongs in Zustand or useState.
- Does this data change after initial render? Static reference data might not need TanStack Query's refetching machinery. Consider if simpler alternatives suffice.
Cache Strategy
- How fresh does this data need to be? Lookup tables can have 1-hour staleTime. Real-time tracking needs 5-second staleTime with refetchInterval. Match configuration to business requirements.
- What's the query lifecycle? Frequently-accessed data needs higher gcTime. One-time detail views can have aggressive garbage collection.
Observer Economics
- How many components will subscribe to this query? >10 observers on a single cache entry suggests hoisting queries to parent. >100 observers indicates architectural issues.
- Am I creating N queries or 1 query with N observers? List items should receive props from parent query, not call individual useQuery hooks.
How to Use
This skill uses progressive disclosure to minimize context usage. Load references based on your scenario:
Scenario 1: Setting Up Query Client
MANDATORY - READ ENTIRE FILE: Read `query-client-setup.md` (~125 lines) and `server-integration.md` (~151 lines) completely for server/client setup patterns. Do NOT Load other references for initial setup.
Copy assets/query-client.ts for production-ready configuration.
Scenario 2: Building Query Hooks
1. MANDATORY: Read `query-keys.md` (~151 lines) for key factory setup 2. If using server components: Read `server-integration.md` 3. Do NOT Load mutations-and-updates.md unless implementing mutations
Use decision tables below for configuration values.
Scenario 3: Implementing Mutations
MANDATORY - READ ENTIRE FILE: Read `mutations-and-updates.md` (~345 lines) completely. Reference `patterns-and-pitfalls.md` for rollback patterns. Do NOT Load caching-strategy.md for basic CRUD mutations.
Scenario 4: Debugging Performance Issues
1. First, check Observer Count Thresholds table below (lines 121-129) 2. If observer count >50: Read `patterns-and-pitfalls.md` 3. If large dataset issues: Read `fundamentals.md` for structural sharing 4. Do NOT Load all references - diagnose first, then load targeted content
Scenario 5: Multi-Layer Caching Strategy
MANDATORY: Read `caching-strategy.md` (~198 lines) for unified Next.js use cache + TanStack Query + HTTP cache patterns. Do NOT Load if only using client-side TanStack Query.
Query Configuration Decision Matrix
| Data Type | staleTime | gcTime | refetchInterval | structuralSharing | Notes |
|---|---|---|---|---|---|
| Reference/Lookup | 1hr | Infinity | - | true | Countries, categories, static enums |
| User Profile | 5min | 10min | - | true | Changes infrequently, moderate freshness |
| Real-time Tracking | 5s | 30s | 5s | false | High update frequency, large payloads |
| Live Dashboard | 2s | 1min | 2s | Depends on size | Balance freshness vs performance |
| Detail View | 30s | 2min | - | true | Fetched on-demand, moderate caching |
| Search Results | 1min | 5min | - | true | Cacheable, not time-sensitive |
Mutation Pattern Selection
| Scenario | Pattern | When to Use |
|---|---|---|
| Form submission | Pessimistic | Multi-step forms, server validation required, error messages needed before proceeding |
| Toggle/checkbox | Optimistic | Binary state changes, low latency required, easy to rollback |
| Drag and drop | Optimistic | Immediate visual feedback essential, reordering operations, non-critical data |
| Batch operations | Pessimistic | Multiple items, partial failures possible, user needs confirmation of what succeeded |
| Life-critical ops | Pessimistic | Medical, financial, safety-critical systems where UI must match server reality |
| Audit trail required | Pessimistic | Compliance systems where operator actions must match logged events exactly |
Query Key Architecture
Use hierarchical factories for consistent invalidation:
// Recommended structure
export const keys = {
all: () => ['domain'] as const,
lists: () => [...keys.all(), 'list'] as const,
list: (filters: string) => [...keys.lists(), filters] as const,
details: () => [...keys.all(), 'detail'] as const,
detail: (id: string) => [...keys.details(), id] as const,
};
// Invalidation examples
queryClient.invalidateQueries({ queryKey: keys.all() }); // Invalidate everything
queryClient.invalidateQueries({ queryKey: keys.lists() }); // Invalidate all lists
queryClient.invalidateQueries({ queryKey: keys.detail(id) }); // Invalidate one itemKey stability rules:
- Deterministic serialization (sort arrays before joining)
- No temporal values (Date.now(), random IDs)
- Type consistency (don't mix '1' and 1)
- Stable object shapes (use sorted keys or serialize)
Server-Client Integration Pattern
| Layer | Purpose | Invalidation Method | Cache Scope |
|---|---|---|---|
| Next.js use cache | Reduce database load | revalidateTag() or updateTag() | Cross-request, server-side |
| TanStack Query | Client-side state management | queryClient.invalidateQueries() | Per-browser-tab |
| Browser HTTP cache | Eliminate network requests | Cache-Control headers | Per-browser |
Unified invalidation strategy: 1. Use same key factories for both server and client caches 2. Server mutations call updateTag(keys.detail(id).tag) 3. Client mutations call queryClient.invalidateQueries({ queryKey: keys.detail(id) }) 4. Both caches stay synchronized with same hierarchy
Observer Count Thresholds
| Observer Count | Performance Impact | Action Required |
|---|---|---|
| 1-5 | Negligible | None |
| 6-20 | Minimal | Monitor, no immediate action |
| 21-50 | Noticeable on updates | Consider hoisting queries to parent |
| 51-100 | Significant overhead | Refactor: hoist queries or use select |
| 100+ | Critical impact | Immediate refactor: single query with props distribution |
Diagnosis: 1. Open TanStack Query DevTools in development 2. Find cache entries with high observer counts 3. Search codebase for useQuery calls with those keys 4. Refactor to parent components or shared cache entries
Query Hook Patterns
| Pattern | Use Case | Example |
|---|---|---|
| useSuspenseQuery | Server Components integration, Suspense boundaries | useSuspenseQuery({ queryKey, queryFn }) |
| useQuery with enabled | Dependent queries, conditional fetching | useQuery({ queryKey, queryFn, enabled: !!userId }) |
| useQuery with select | Data transformation, subset selection | useQuery({ queryKey, queryFn, select: selectFn }) — extract selectFn to a stable module-level variable; inline functions re-run on every render |
| useMutation optimistic | Low-latency UI updates, easily reversible | useMutation({ onMutate, onError, onSettled }) |
| useMutation pessimistic | High-stakes operations, server validation | useMutation({ onSuccess }) |
Common Error Patterns and Fixes
| Symptom | Root Cause | Solution | Fallback if Solution Fails |
|---|---|---|---|
| Data doesn't update after save | Copied query data to useState | Use query data directly, derive with useMemo | Force refetch with refetch() method, check network tab for actual API response |
| Infinite requests | Unstable query keys (Date.now(), unsorted arrays) | Use deterministic key construction | Add staleness detection: const requestCount = useRef(0); useEffect(() => { requestCount.current++; if (requestCount.current > 10) console.error('Infinite loop detected', queryKey); }, [data]); See fundamentals.md for key stability patterns |
| N duplicate requests | Query in every list item | Hoist query to parent, pass data as props | Ensure all components use identical queryKey (same object reference or values): const queryKey = useMemo(() => keys.list(filters), [filters]); Increase staleTime to 30s to deduplicate rapid requests |
| Query fires with undefined params | Missing enabled guard | Add enabled: Boolean(dependency) | Use placeholderData to show loading state, add type guards in queryFn to throw early |
| Slow list rendering | N queries + N observers | Single parent query, distribute via props | Use select to subscribe to subset, implement virtual scrolling to reduce mounted components |
| Cache never clears | gcTime: Infinity on frequently-changing data | Match gcTime to data lifecycle | Force removal with queryClient.removeQueries(), monitor cache size with DevTools |
| UI shows stale data flash | Server cache stale, client cache fresh | Unified invalidation with same keys | Use initialData from server props, set refetchOnMount: false for hydrated queries |
| Optimistic update won't rollback | onError not restoring context | Use context from onMutate in onError | Force invalidation with invalidateQueries, implement manual rollback with previous state snapshot |
| Server hydration mismatch | Timestamp/user-specific data in SSR | Use suppressHydrationWarning on container | Client-only rendering with dynamic import and ssr: false, or normalize timestamps to UTC |
| Query never refetches | enabled: false guard blocking, or gcTime expired | Check enabled conditions, verify query isn't filtered by predicate | Increase gcTime to keep cache alive longer, use refetchInterval for polling behavior, check if staleTime: Infinity is preventing background refetches |
| Server action not invalidating | updateTag/revalidateTag using different keys than queryClient | Use same key factories for both server and client caches | Manually call router.refresh() after server action, verify tag names match query key hierarchy |
| Mutation succeeds but UI doesn't update | Missing onSuccess invalidation or wrong queryKey | Add onSuccess: () => queryClient.invalidateQueries({ queryKey }) | Use setQueryData to manually update cache: queryClient.setQueryData(keys.detail(id), newData), verify queryKey matches exactly |
Troubleshooting Decision Tree
Performance Issues
Step 1: Check observer count in DevTools (use thresholds at lines 136-145)
- >100 observers → Immediate refactor: hoist queries to parent component, distribute data via props
- 51-100 observers → Refactor: hoist queries or use select to subscribe to data subsets
- <50 observers → Issue is elsewhere, continue to Step 2
Step 2: Check data size and update frequency
- >1000 items + frequent updates → Disable structural sharing:
structuralSharing: false(see fundamentals.md for details) - Large payloads (>500KB) → Check network tab, consider pagination or infinite queries
- Fast updates (<1s interval) → Lower staleTime or use refetchInterval, verify cache strategy
Step 3: Check React DevTools Profiler
- Look for unnecessary re-renders in components using query data
- Verify select function isn't recreated on every render (use useCallback)
- Check if derived data should use useMemo instead of inline transformation
- Profile component render times to identify bottlenecks
Network Issues
Flaky connections:
- Configure retry logic:
retry: 3, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000) - See query-client-setup.md for production retry configuration
Token refresh needed:
- Implement auth interceptor pattern in queryFn wrapper
- Use queryClient.setQueryDefaults() for global auth headers
- See patterns-and-pitfalls.md for token refresh patterns
Race conditions:
- Review invalidation timing: use cancelQueries before setQueryData
- Check if optimistic updates compete with background refetches
- Verify mutation onMutate uses await cancelQueries({ queryKey })
Hydration Issues
SSR mismatch (hydration error in console):
- Add suppressHydrationWarning to container element
- Normalize data: ensure server and client produce identical output (stable timestamps, sorted arrays)
- Check if user-specific data is being rendered server-side
Client-server data drift:
- Verify revalidateTag timing on server mutations
- Check if server cache (Next.js use cache) is stale while client cache is fresh
- Use initialData from server props:
initialData: serverData, refetchOnMount: false
HydrationBoundary not working:
- Verify client component boundaries: HydrationBoundary must wrap 'use client' components
- Check if dehydratedState is being serialized correctly from server
- Ensure shouldDehydrateQuery includes queries you want to hydrate
Freedom Calibration
Calibrate guidance specificity to mutation risk:
| Task Type | Freedom Level | Guidance Format | Example |
|---|---|---|---|
| Query configuration | High freedom | Principles with tables for common patterns | "Match staleTime to business requirements" |
| Optimistic updates | Medium freedom | Complete pattern with rollback handling | "Use onMutate/onError/onSettled callbacks" |
| QueryClient setup | Low freedom | Exact code with critical security warning | "NEVER use singleton on server - use factory" |
The test: "If the agent makes a mistake, what's the consequence?"
- Server singleton mistake → Data leakage between users (critical security issue)
- Observer count mistake → Performance degradation (medium impact)
- staleTime tuning → Suboptimal freshness (low impact)
Important Notes
- Query keys are hashed deterministically - ['tracks', '1'] and ['tracks', 1] create different cache entries
- Query keys must be JSON-serializable for cache persistence across page reloads and hydration
- shouldDehydrateQuery with pending status enables streaming without await in server components
- HydrationBoundary must wrap client components only - server components bypass the boundary
- revalidateTag vs updateTag matters: revalidateTag uses stale-while-revalidate, updateTag invalidates immediately
- Background refetches run even when no components are mounted if gcTime hasn't expired
- Structural sharing runs twice when using select: once on raw data, once on transformed data
selectonly runs on successfully cached data — it is never called in error states; put validation and error throwing inqueryFn- cancelQueries in onMutate is critical - background refetches can overwrite optimistic updates
- Context returned from onMutate is passed to onError and onSettled for rollback state
╭────────────────────────────────────────╮ │ accelint-tanstack-query-best-practices │ ╰────────────────────────────────────────╯
<!-- Make sure to display this warning block to the user --> ┌──────────────────────────────────────────────────────┐ │ ⚠️ WARNING: This skill does it's best to process │ │ the context needed to suggest correct best practices │ │ but it can make mistakes. Please make sure to read │ │ the summary section of each issue to make sure it │ │ isn't a false positive. │ └──────────────────────────────────────────────────────┘
Report: [Target Name]
<!-- INSTRUCTIONS FOR COMPLETING THIS TEMPLATE:
1. Replace [Target Name] with the specific file/module being audited (e.g., "User Authentication", "Data Processing Utils")
2. EXECUTIVE SUMMARY: Provide a high-level overview
- Summarize what was audited and the scope (query hooks, mutations, QueryClient setup, etc.)
- Count issues by severity and category
- Include Impact Assessment explaining TanStack Query-specific risks (data leakage, infinite requests, observer economics, cache invalidation issues)
3. PHASE 1 - ISSUE GROUPING RULES:
- Group issues when they share the SAME root cause AND same fix pattern
- Example: Multiple instances of missing memoization → group together
- Example: Different safety violations → separate issues
- Use subsections (4-8) for grouped issues, individual numbers (1, 2, 3) for unique issues
4. PHASE 1 - EACH ISSUE/GROUP MUST INCLUDE:
- Location (file:line or file:line-range)
- Current code with ❌ marker
- Clear explanation of the issue
- Severity (Critical, High, Medium, Low)
- Category (Derived State, Safety, State Management, Hoisting Static JSX, Code Quality, etc...)
- Impact (potential bugs, maintainability concerns, runtime failures)
- Pattern Reference (which references/*.md file)
- Recommended Fix with ✅ marker
5. SEVERITY LEVELS:
- Critical: Could cause data leakage, infinite requests, app crashes, or security vulnerabilities
Examples: singleton QueryClient on server, unstable query keys with Date.now(), missing AbortController support
- High: Causes N duplicate requests, stale data bugs, or significant performance degradation
Examples: N queries in list items, synchronizing query data to useState, missing enabled guards on dependent queries
- Medium: Suboptimal patterns affecting performance or cache efficiency
Examples: high observer count (50-100), structural sharing on large datasets, missing onSettled in optimistic updates
- Low: Minor configuration optimizations and cache tuning
Examples: could adjust staleTime/gcTime for data lifecycle, could disable structural sharing for better performance
6. CATEGORIES:
- Query Configuration: staleTime, gcTime, refetchInterval, structuralSharing, retry settings
- Query Keys: Stability (deterministic serialization), hierarchy (factory patterns), temporal values
- Observer Economics: N queries vs N observers, hoisting patterns, parent-child data flow
- Mutations: Optimistic vs pessimistic patterns, onMutate/onError/onSettled lifecycle, rollback handling
- Cache Invalidation: invalidateQueries, setQueryData, cancelQueries, structural sharing preservation
- Server Integration: QueryClient singleton issues, HydrationBoundary, SSR/SSG patterns, dehydration
- Performance: Structural sharing overhead, observer count, data size, network efficiency
- Code Quality: Query hook patterns, enabled guards, dependent queries, AbortController integration
7. IMPACT FIELD SHOULD DESCRIBE:
- Data leakage risks (server singleton causing cross-user cache pollution)
- Network efficiency (N duplicate requests, infinite request loops)
- Cache performance (high observer count causing O(n) iterations on every update)
- Stale data bugs (synchronizing to useState, missing invalidation)
- Hydration issues (SSR mismatches, HydrationBoundary problems)
- Mutation correctness (missing rollback, optimistic updates without onSettled)
- Observer economics (list items with individual queries vs parent query)
- Maintainability concerns (unstable keys, missing enabled guards, no AbortController)
8. PHASE 2: Generate summary table from Phase 1 findings
- Include all issues with their numbers
- Keep it concise - one row per issue/group
See assets/audit-report-example.md for a real-world example. -->
Executive Summary
Completed systematic audit of [file/module path] following accelint-tanstack-query-best-practices standards. Identified [N] TanStack Query configuration and usage issues across [N] severity levels. [Brief description of what this feature does and how it uses TanStack Query].
Key Findings:
- [N] Critical issues (server singleton QueryClient, unstable query keys, data leakage risks)
- [N] High severity issues (N queries in list items, missing enabled guards, synchronizing to useState)
- [N] Medium severity issues (suboptimal cache configuration, high observer count, missing onSettled)
- [N] Low severity issues (minor optimizations, cache tuning opportunities)
Impact Assessment: [Explain the overall TanStack Query usage patterns and concerns. Consider:]
- Are there data leakage risks from server singleton QueryClient usage?
- Are there infinite request loops from unstable query keys?
- How many observers are subscribed to each cache entry? (use DevTools to check)
- Are queries properly hoisted or are list items creating N duplicate requests?
- Are mutations using appropriate optimistic/pessimistic patterns for the use case?
- Are there cache invalidation issues or stale data problems?
- Are server-client integration patterns correct for SSR/hydration?
---
Phase 1: Identified Issues
1. [Function/Location] - [Issue Type]
Location: [file:line] or [file:line-range]
// ❌ Current: [Brief description of problem]
// Example: useQuery({ queryKey: ['data', Date.now()], ... })
[code snippet showing the TanStack Query issue]Issue:
- [Point 1 explaining the problem - reference NEVER/Before patterns from SKILL.md]
- [Point 2 with specifics about the violation - reference pattern files]
- [Point 3 quantifying the impact - observer count, network requests, cache pollution, etc.]
Severity: [Critical|High|Medium|Low] Category: [Query Configuration|Query Keys|Observer Economics|Mutations|Cache Invalidation|Server Integration|Performance|Code Quality] Impact:
- Data integrity: [Cross-user leakage, stale data bugs, cache corruption]
- Network efficiency: [Duplicate requests, infinite loops, wasted bandwidth]
- Cache performance: [Observer count impact, structural sharing overhead]
- Correctness: [Missing rollback, hydration mismatches, unstable keys]
Pattern Reference: [references/filename.md from the skill]
Recommended Fix:
// ✅ [Brief description of solution]
// Example: Use stable key factory: useQuery({ queryKey: keys.detail(id), ... })
[code snippet showing the TanStack Query fix with proper patterns]---
2. [Function/Location] - [Issue Type]
Location: [file:line] or [file:line-range]
// ❌ Current: [Brief description of problem]
[code snippet]Issue:
- [Explanation]
Severity: [Critical|High|Medium|Low] Category: [Query Configuration|Query Keys|Observer Economics|Mutations|Cache Invalidation|Server Integration|Performance|Code Quality] Impact:
- Data integrity: [Cross-user leakage, stale data bugs, cache corruption]
- Network efficiency: [Duplicate requests, infinite loops, wasted bandwidth]
- Cache performance: [Observer count impact, structural sharing overhead]
- Correctness: [Missing rollback, hydration mismatches, unstable keys]
Pattern Reference: [references/filename.md from the skill]
Recommended Fix:
// ✅ [Brief description of solution]
[code snippet]---
3-N. [Grouped Issues] - [Shared Issue Type] ([N] instances)
<!-- Use this format when multiple issues share the same root cause and fix pattern -->
Locations:
[file:line]- [function/context][file:line]- [function/context][file:line]- [function/context]
Example from [specific location]:
// ❌ Current: [Brief description of problem]
[representative code snippet]Issue:
- [Shared root cause explanation]
- [Why this pattern is problematic]
- [Impact across all instances]
Severity: [Critical|High|Medium|Low] Category: [Query Configuration|Query Keys|Observer Economics|Mutations|Cache Invalidation|Server Integration|Performance|Code Quality] Impact:
- Data integrity: [Cross-user leakage, stale data bugs across all instances]
- Network efficiency: [Duplicate requests, wasted bandwidth across N locations]
- Cache performance: [Observer count impact, structural sharing overhead]
- Correctness: [Missing rollback, hydration mismatches, unstable keys]
Pattern Reference: [references/filename.md from the skill]
Recommended Fix:
// ✅ [Brief description of solution]
[fixed code snippet]Same pattern applies to all [N] instances:
// [Location/function 2]
// ❌ Current
[code snippet]
// ✅ Better
[fixed snippet]
// [Location/function 3]
// ❌ Current
[code snippet]
// ✅ Better
[fixed snippet]---
Phase 2: Categorized Issues
| # | Location | Issue | Category | Severity |
|---|---|---|---|---|
| 1 | [file:line] | [Brief issue description] | [Category] | [Severity] |
| 2 | [file:line] | [Brief issue description] | [Category] | [Severity] |
| 3 | [file:line] | [Brief issue description] | [Category] | [Severity] |
| 4-N | [multiple] | [Brief issue description] | [Category] | [Severity] |
Total Issues: [N] By Severity: Critical ([N]), High ([N]), Medium ([N]), Low ([N]) By Category: [Category1] ([N]), [Category2] ([N]), [Category3] ([N])
// configs/query-client/index.ts
//
// Query client factory pattern for Next.js App Router with Server Components.
// Creates isolated query clients per-request on server, singleton in browser.
import {
QueryClient,
defaultShouldDehydrateQuery,
isServer,
} from '@tanstack/react-query';
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
// Queries are fresh for 20 seconds, preventing unnecessary refetches
staleTime: 1000 * 20,
// Inactive queries are garbage collected after 2 minutes
gcTime: 1000 * 60 * 2,
// Retry failed requests 3 times with exponential backoff
retry: 3,
// Disable automatic refetching when user focuses window
// Enable per-query for real-time data
refetchOnWindowFocus: false,
},
dehydrate: {
// Include pending queries in dehydration for streaming support
// This allows prefetch calls without await - queries stream to client
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
},
},
});
}
let browserQueryClient: QueryClient | undefined = undefined;
export function getQueryClient() {
if (isServer) {
// Server: ALWAYS create a new query client per request
// CRITICAL: Prevents data leakage between users and race conditions
return makeQueryClient();
} else {
// Browser: Create a singleton to maintain cache across navigations
browserQueryClient ??= makeQueryClient();
return browserQueryClient;
}
}
Caching Strategy
Note: this reference requires knowledge from the accelint-nextjs-best-practices and next-cache-components skills. Please refer to those skills to contextualize the full caching strategy across server and client.
Three-Layer Architecture
Server-side architecture combines three distinct caching mechanisms:
| Layer | Purpose | Invalidation | Scope |
|---|---|---|---|
| Next.js use cache | Reduce database load | revalidateTag() / updateTag() | Cross-request, server-side |
| TanStack Query | Client state management | queryClient.invalidateQueries() | Per-browser-tab |
| Browser HTTP cache | Eliminate network requests | Cache-Control headers | Per-browser |
Each layer serves a distinct purpose:
use cachereduces database load by caching server computations- TanStack Query provides instant UI feedback with cached data while fetching fresh updates
- Browser cache eliminates network requests for static resources
These layers are complementary, not redundant. use cache runs server-side before TanStack Query fires. TanStack Query provides client features (staleTime, refetchInterval, optimistic updates) that server caching can't replicate.
Unified Invalidation with Shared Keys
Use the same key factories for both server and client caches:
// data-access/tracks/keys.ts
type TaggedKey<T extends readonly string[]> = T & { readonly tag: string }
function key<T extends readonly string[]>(parts: T): TaggedKey<T> {
const arr = [...parts] as unknown as TaggedKey<T>
Object.defineProperty(arr, 'tag', { get: () => parts.join(':'), enumerable: false })
return arr
}
export const keys = {
all: () => key(['tracks']),
details: () => key([...keys.all(), 'detail']),
detail: (id: string) => key([...keys.details(), id]),
};Server-side with use cache:
// data-access/tracks/server.ts
export async function getOne(id: string) {
'use cache';
cacheTag(keys.detail(id).tag); // .tag serializes to 'tracks:detail:id'
const rawData = await db.query('SELECT * FROM tracks WHERE id = $1', [id]);
return trackSchema.parse(rawData);
}
export async function update(id: string, payload: Partial<Track>) {
const validated = trackSchema.partial().parse(payload);
await db.query('UPDATE tracks SET lon = $1, lat = $2 WHERE id = $3',
[validated.lon, validated.lat, id]);
updateTag(keys.detail(id).tag); // Immediate invalidation
}Client-side with TanStack Query:
// data-access/tracks/client.ts
export function useTrack(id: string) {
return useSuspenseQuery({
queryKey: keys.detail(id), // Same factory
queryFn: () => getOne(id),
});
}
export function useUpdateTrack(id: string) {
const queryClient = getQueryClient();
return useMutation({
mutationFn: async (data: FormData) => {
const result = await updateTrackAction(id, data);
return result;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: keys.detail(id) }); // Same keys
},
});
}Server Mutation Invalidates Both Caches
When mutations happen via Server Actions:
// actions/tracks.ts
'use server';
import { updateTag } from 'next/cache';
import { keys } from '~/data-access/tracks/keys';
export async function updateTrack(id: string, formData: FormData) {
// ... perform update
// Invalidate server-side cache (Next.js use cache)
updateTag(keys.detail(id).tag);
updateTag(keys.all().tag);
// Client-side invalidation handled by TanStack Query mutation callback
}The client-side mutation wrapper calls the Server Action and then invalidates TanStack Query cache in onSuccess.
revalidateTag vs updateTag
Critical difference in Next.js 16+:
- revalidateTag: Stale-while-revalidate. Current request sees stale data, next request gets fresh data
- updateTag: Immediate invalidation. Current request sees fresh data
Use updateTag when users need to see their own mutations reflected immediately. Use revalidateTag for background updates where stale-while-revalidate is acceptable.
Integration Pattern
Problem: Dueling caches 1. Server cache (use cache) serves stale data to HTML 2. Client cache (TanStack Query) has fresh data 3. User sees stale data flash, then fresh data (layout shift)
Solution: Unified invalidation 1. Mutation updates database 2. updateTag(keys.detail(id).tag) busts server cache 3. queryClient.invalidateQueries({ queryKey: keys.detail(id) }) busts client cache 4. Next render hits database for both server and client 5. Both caches repopulate with same fresh data
The shared key factory (keys.ts) makes this work. One mutation, one key hierarchy, both caches invalidated atomically.
Cache Tag Strategy
Tag cached entries with hierarchical identifiers:
❌ Incorrect: hardcoded tags
export async function getOne(id: string) {
'use cache';
cacheTag('tracks', 'detail', id); // Manual, error-prone
}
// Different format in client
queryKey: ['track', 'details', id] // Typo creates cache inconsistency✅ Correct: shared factory
export async function getOne(id: string) {
'use cache';
cacheTag(keys.detail(id).tag); // .tag gives consistent serialized string
}
queryKey: keys.detail(id) // Consistent hierarchyHydration Pattern
Server prefetches using use cache functions, client hydrates TanStack Query cache:
// features/tracks/server.tsx
export async function TrackDetailServer({ id }: { id: string }) {
const queryClient = getQueryClient();
prefetchOne(queryClient, id); // Calls getOne() which uses 'use cache'
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<TracksClient trackId={id} />
</HydrationBoundary>
);
}Server calls getOne(id) which uses use cache. TanStack Query on client receives prefetched data and populates its cache. Both caches have same data, keyed the same way, invalidated the same way.
Cache Key Stability
Arguments to cached functions become part of the cache key. Unstable arguments create cache thrash.
❌ Incorrect: unstable cache keys
// New object instance on every call - infinite cache entries
'use cache';
cacheTag('users');
const filters = { status: 'active' }; // New reference each time
return getUsers(filters);✅ Correct: stable serialization
'use cache';
cacheTag('users', JSON.stringify(filters)); // Deterministic serialization
return getUsers(filters);Important Notes
revalidateTaguses stale-while-revalidate (current request sees old data)updateTaginvalidates immediately (current request sees fresh data after refetch)- Shared key factories prevent server/client cache desync
use cachewithcacheTagprovides server-side caching before TanStack Query runs- Both layers need invalidation on mutations to prevent UI showing stale data flash
Core Concepts and Performance
Terminology
staleTime
Duration until a query transitions from fresh to stale. Fresh queries never trigger network requests.
- Fresh query: Within staleTime window, returns cached data immediately, no network request
- Stale query: Past staleTime window, returns cached data but triggers background refetch
Default: 0 (immediately stale)
gcTime
Duration until inactive queries are removed from cache. Previously called cacheTime in v4.
- Active query: Has at least one observer (component using the query)
- Inactive query: No observers, countdown to garbage collection starts
Default: 5 minutes
Observers
Internal subscribers that watch for query state changes. Think event listeners. Each useQuery or useSuspenseQuery call registers an observer.
When data updates, TanStack Query iterates through all registered observers for that cache entry to determine which components need to re-render.
Fresh vs Stale Queries
// Lookup data: long staleTime
useQuery({
queryKey: keys.countries(),
queryFn: fetchCountries,
staleTime: 1000 * 60 * 60, // Fresh for 1 hour
});
// First render: network request
// Renders within 1 hour: cached data, no network request
// After 1 hour: cached data shown, background refetch triggered
// Real-time data: short staleTime
useQuery({
queryKey: keys.track(id),
queryFn: () => fetchTrack(id),
staleTime: 1000 * 5, // Fresh for 5 seconds
});
// Frequent refetching to stay currentQuery State Lifecycle
idle → pending → success/error
↓
refetching → success/error- idle: Query hasn't been triggered yet
- pending: Initial fetch in progress
- success: Data fetched successfully, cached
- error: Fetch failed, error cached
- refetching: Background refetch of stale data (still showing old data)
Structural Sharing
TanStack Query uses structural sharing to prevent unnecessary re-renders. After refetch, performs deep equality check:
- If data is referentially different but structurally identical (same values, different object instances), returns previous reference
- Prevents downstream re-renders when API returns fresh data with identical values
Complexity: O(n) where n = number of fields in data structure
When to Disable Structural Sharing
| Dataset Size | Update Frequency | Recommendation |
|---|---|---|
| <100 items | Any | Keep enabled (default) |
| 100-1000 items | <1 update/second | Keep enabled |
| 1000-5000 items | >1 update/second | Consider disabling |
| >5000 items | Any | Disable with structuralSharing: false |
// Large, frequently-changing data
useQuery({
queryKey: keys.radarContacts(),
queryFn: fetchRadarContacts,
staleTime: 1000 * 2,
structuralSharing: false, // Skip expensive diffing
});Select Option Double Overhead
When using select, structural sharing runs twice: 1. First pass: Compare previous raw data with new raw data 2. Second pass: Compare previous transformed data with new transformed data
For large datasets (>1000 items) with frequent updates, double structural sharing adds overhead. Disable with structuralSharing: false.
// Stable selector — extract outside hook so reference doesn't change on re-render
const selectSorted = (items: Item[]) => items.sort((a, b) => a.timestamp - b.timestamp);
// Disable structural sharing with select on large data
export function useItemsSorted() {
return useSuspenseQuery({
queryKey: keys.all(),
queryFn: fetchItems,
select: selectSorted,
refetchInterval: 1000,
structuralSharing: false, // Skip double overhead
});
}Observer Performance Impact
Each useQuery call creates an observer. High observer counts cause O(n) iteration overhead on every cache update.
Observer Count Thresholds
| Observer Count | Performance Impact | Action Required |
|---|---|---|
| 1-5 | Negligible | None |
| 6-20 | Minimal | Monitor, no immediate action |
| 21-50 | Noticeable on updates | Consider hoisting queries to parent |
| 51-100 | Significant overhead | Refactor: hoist queries or use select |
| 100+ | Critical impact | Immediate refactor: single query with props distribution |
Diagnosis with DevTools
1. Open TanStack Query DevTools in development 2. Find cache entries with observer counts >10 3. Identify the query keys with excessive observers 4. Search codebase for query hook calls with those keys 5. Refactor to hoist queries to parent components
Solution Patterns
Problem: N list items, N observers, N requests
// ❌ 200 observers, 200 requests
function TrackList({ trackIds }) {
return trackIds.map(id => <TrackItem key={id} trackId={id} />);
}
function TrackItem({ trackId }) {
const { data } = useTrack(trackId); // Observer per item
return <div>{data.name}</div>;
}Solution 1: Hoist query to parent
// ✅ 1 observer, 1 request
function TrackList({ trackIds }) {
const { data: tracks } = useAllTracks();
return trackIds.map(id => {
const track = tracks.find(t => t.id === id);
return <TrackItem key={id} track={track} />;
});
}
function TrackItem({ track }) {
return <div>{track.name}</div>;
}Solution 2: Use select to minimize re-renders
const selectTrackName = (track: Track) => track.name;
// ✅ Observers still exist but select reduces re-render frequency
export function useTrackName(id: string) {
return useSuspenseQuery({
queryKey: keys.detail(id),
queryFn: () => fetchTrack(id),
select: selectTrackName, // Stable reference
});
}Solution 3: Populate individual caches from list query
function TrackList({ trackIds }) {
const queryClient = getQueryClient();
const { data: tracks } = useAllTracks();
// Populate individual track caches from list
useEffect(() => {
tracks.forEach(track => {
queryClient.setQueryData(keys.detail(track.id), track);
});
}, [tracks, queryClient]);
return trackIds.map(id => <TrackItem key={id} trackId={id} />);
}
// Now useTrack(id) returns immediately from pre-populated cache
function TrackItem({ trackId }) {
const { data } = useTrack(trackId);
return <div>{data.name}</div>;
}Background Refetching Strategies
Balance freshness requirements with network efficiency:
Static Reference Data
export function useCountries() {
return useSuspenseQuery({
queryKey: keys.all(),
queryFn: fetchCountries,
staleTime: 1000 * 60 * 60, // 1 hour - rarely changes
gcTime: Infinity, // Keep forever
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
});
}Moderate Freshness
export function useUserProfile() {
return useSuspenseQuery({
queryKey: keys.current(),
queryFn: fetchUserProfile,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 10, // 10 minutes
refetchOnWindowFocus: true, // Refetch when user returns
});
}Real-Time Data
export function useTrack(id: string) {
return useSuspenseQuery({
queryKey: keys.detail(id),
queryFn: () => fetchTrack(id),
staleTime: 1000 * 5, // 5 seconds
gcTime: 1000 * 30, // 30 seconds
refetchInterval: 1000 * 5, // Poll every 5 seconds
refetchOnWindowFocus: true,
});
}Query Deduplication
TanStack Query automatically deduplicates identical queries. When three components request same data simultaneously, one network request is made and result shared across all three.
How it works:
- Query key is hashed to create cache entry identifier
- First query hook registers observer and triggers fetch
- Second and third hooks register observers on existing cache entry
- All three hooks receive same data when fetch completes
No action needed - automatic behavior. Just ensure consistent query keys across components.
Important Notes
- Fresh queries (within staleTime) never trigger network requests
- Stale queries return cached data immediately, then trigger background refetch
- gcTime countdown starts when last observer unmounts
- Structural sharing is O(n) - disable for datasets >5000 items with frequent updates
selectoption doubles structural sharing overhead - disable sharing for large datasets- Observer counts >50 indicate architectural issues requiring refactoring
- Query deduplication is automatic - focus on consistent query keys
- Tune staleTime/refetchInterval based on data freshness requirements, not one-size-fits-all
- Use TanStack Query DevTools to diagnose observer count and cache issues
Mutations and Client Hooks
Query Hooks
useSuspenseQuery Best Practices
Use useSuspenseQuery for server-hydrated data and Suspense boundary integration:
// data-access/tracks/client.ts
'use client';
import { useSuspenseQuery } from '@tanstack/react-query';
import { getQueryClient } from '~/configs/query-client';
import { keys } from './keys';
export function useTrack(id: string) {
return useSuspenseQuery({
queryKey: keys.detail(id),
queryFn: () => $fetch<Track>('/:id', { method: 'GET', params: { id } }),
refetchInterval: 1000, // Poll every second for real-time updates
});
}Benefits over useQuery:
- Throws promises that Suspense boundaries catch
- No undefined states - data always exists when component renders
- Works seamlessly with server-side prefetching
Query Cancellation with AbortController
Pass signal to fetch requests for proper cleanup:
export function useData() {
return useQuery({
queryKey: ['my-data'],
queryFn: async ({ signal }) => {
return $fetch('/api/data', { signal }); // Pass signal through
},
});
}TanStack Query aborts in-flight requests when:
- Component unmounts
- Query key changes
- Query manually cancelled
Without signal support, unmounted components leave requests running, wasting bandwidth and potentially updating stale cache entries. See patterns-and-pitfalls.md#query-cancellation for anti-patterns.
Data Transformation with select
Transform data close to the query definition:
function Component() {
const { data } = useQuery({
queryKey: keys.all(),
queryFn: fetchItems,
select: (items) => items.sort((a, b) => a.timestamp - b.timestamp),
});
}select only runs when data exists (no undefined checks) and sits next to query definition. See patterns-and-pitfalls.md#data-transformation for comparison with useMemo.
Mutation Patterns Overview
Mutations modify server state and update client cache. Two primary patterns:
| Pattern | When to Use | Examples |
|---|---|---|
| Pessimistic | Server validation required, high-stakes operations, batch operations, audit trails | Form submission, payment processing, batch operations |
| Optimistic | Low-latency requirement, user-initiated, easily reversible, non-critical data | Toggle switches, likes, drag-and-drop, user preferences |
Pessimistic Updates
Default pattern for most mutations. Update cache only after server confirms success.
export function useCreateTrack() {
const queryClient = getQueryClient();
return useMutation({
mutationFn: (payload: CreateTrackPayload) => {
const validated = trackSchema.omit({ id: true }).parse(payload);
return $fetch<Track>('/', { method: 'POST', body: validated });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: keys.all() });
},
});
}When to use pessimistic updates:
- High-stakes operations: Financial transactions, medical data, safety-critical systems
- Server validation required: Multi-step forms, complex business rules, external dependencies
- External mutations: Changes triggered by other users, system events, or background processes
- Audit trail required: Compliance systems where operator actions must match logged events exactly
- Partial failures possible: Batch operations where some items succeed and others fail
Examples:
- Payment processing
- Medical prescription changes
- Batch delete operations
- Life-critical command and control operations
- Regulatory compliance workflows
Optimistic Updates
Update cache immediately before server confirms. If mutation fails, rollback to previous state.
When to Use Optimistic Updates
Optimistic updates immediately reflect user changes in UI before server confirmation. When mutation completes, UI reconciles with server response. If mutation fails, UI rolls back to previous state.
Use optimistic updates when:
- Low-latency requirement: User expects instant feedback (toggle switches, likes, status changes)
- User-initiated mutations: Actions directly triggered by user interaction
- Easily reversible: Failed mutations can be rolled back without ambiguity
- Non-critical data: Incorrect temporary state doesn't cause serious issues
Examples:
- Toggle bookmark status
- Increment like counter
- Reorder list items via drag-and-drop
- Update user preferences
- Mark notification as read
Implementation Pattern
The pattern uses TanStack Query's mutation callbacks for three phases: optimistic application, error rollback, and server reconciliation.
// data-access/tracks/client.ts
'use client';
import { useMutation } from '@tanstack/react-query';
import { getQueryClient } from '~/configs/query-client';
import { keys } from './keys';
import type { Track } from './types';
export function useUpdateTrack(id: string) {
const queryClient = getQueryClient();
return useMutation({
mutationFn: (payload: Partial<Track>) => {
const validated = trackSchema.partial().parse(payload);
return $fetch<Track>(':id', {
method: 'PATCH',
params: { id },
body: validated,
});
},
onMutate: async (newData) => {
// Cancel outgoing refetches to prevent race conditions
await queryClient.cancelQueries({ queryKey: keys.detail(id) });
// Snapshot the previous value for rollback
const previous = queryClient.getQueryData<Track>(keys.detail(id));
// Optimistically update the cache
if (previous) {
queryClient.setQueryData<Track>(keys.detail(id), {
...previous,
...newData,
});
}
// Return context object with snapshot
return { previous };
},
onError: (err, variables, context) => {
// Rollback to previous state on failure
if (context?.previous) {
queryClient.setQueryData(keys.detail(id), context.previous);
}
},
onSettled: () => {
// Refetch to ensure cache matches server state
queryClient.invalidateQueries({ queryKey: keys.detail(id) });
},
});
}Critical Implementation Details
onMutate Phase
- Runs synchronously before mutation starts
- Cancel in-flight queries with
cancelQueriesto prevent race conditions where background refetch overwrites optimistic update - Snapshot previous state via
getQueryData - Apply optimistic update via
setQueryData - Return context object containing snapshot - TanStack Query passes this to
onErrorandonSettled
onError Phase
- Receives the context from
onMutate - Rolls back cache to the snapshot
- Without this, failed mutation leaves UI showing data that doesn't exist on server
onSettled Phase
- Runs after both success and failure
- Use
invalidateQueriesto refetch from server - Reconciles any drift between client and server state
- Critical for ensuring eventual consistency
Validation Before Mutation
Always validate payload before mutation to catch errors before reaching server:
mutationFn: (payload: Partial<Track>) => {
const validated = trackSchema.partial().parse(payload); // Throws if invalid
return $fetch<Track>(':id', { method: 'PATCH', params: { id }, body: validated });
}Validation failures trigger onError callback, which rolls back optimistic update.
Multiple Cache Entry Updates
When optimistic update affects multiple cache entries:
export function useUpdateTrack(id: string) {
const queryClient = getQueryClient();
return useMutation({
mutationFn: (payload: Partial<Track>) => updateTrack(id, payload),
onMutate: async (newData) => {
// Cancel all related queries
await queryClient.cancelQueries({ queryKey: keys.detail(id) });
await queryClient.cancelQueries({ queryKey: keys.all() });
// Snapshot both cache entries
const previousDetail = queryClient.getQueryData<Track>(keys.detail(id));
const previousList = queryClient.getQueryData<Track[]>(keys.all());
// Update detail view
if (previousDetail) {
queryClient.setQueryData<Track>(keys.detail(id), {
...previousDetail,
...newData,
});
}
// Update item in list view
if (previousList) {
queryClient.setQueryData<Track[]>(
keys.all(),
previousList.map(t => t.id === id ? { ...t, ...newData } : t)
);
}
return { previousDetail, previousList };
},
onError: (err, variables, context) => {
// Rollback both cache entries
if (context?.previousDetail) {
queryClient.setQueryData(keys.detail(id), context.previousDetail);
}
if (context?.previousList) {
queryClient.setQueryData(keys.all(), context.previousList);
}
},
onSettled: () => {
// Refetch both to ensure consistency
queryClient.invalidateQueries({ queryKey: keys.detail(id) });
queryClient.invalidateQueries({ queryKey: keys.all() });
},
});
}Custom Hooks
Always wrap queries in custom hooks for encapsulation. See patterns-and-pitfalls.md#custom-hooks-and-closures for detailed explanation of closure bugs.
// data-access/tracks/client.ts
export function useTrack(id: string, userId: string) {
return useQuery({
queryKey: keys.detail(id),
queryFn: () => fetchTrack(id, userId), // Fresh values on each render
});
}
// features/tracks/client.tsx
function TrackView({ trackId }) {
const userId = useUserId();
const { data: track } = useTrack(trackId, userId);
}Benefits:
- Prevents closure bugs where queryFn captures stale variables
- Centralized query configuration
- Consumers don't need to know query keys
- Easy to update query options in one place
- Better testability
Configured Fetch Instance
Create domain-specific fetch instances:
// data-access/tracks/client.ts
import { createFetch } from '@better-fetch/fetch';
const $fetch = createFetch({
baseURL: '/api/tracks',
retry: 0, // TanStack Query handles retries
timeout: 10000,
throw: true, // Throw for non-2xx (works with TanStack Query error boundaries)
headers: {
'content-type': 'application/json',
},
});
export function useAllTracks() {
return useSuspenseQuery({
queryKey: keys.all(),
queryFn: () => $fetch<Track[]>('/', { method: 'GET' }),
refetchInterval: 5000,
});
}Why throw: true? TanStack Query's error boundaries expect thrown errors. Without it, you'd need manual response.ok checks.
Why retry: 0? Let TanStack Query handle retries with its configurable retry logic.
Important Notes
- Always validate payloads before mutations with Zod schemas
- Use
useSuspenseQueryfor server-hydrated data - Configure
@better-fetch/fetchwiththrow: truefor error boundary integration - Set
retry: 0in fetch config - let TanStack Query handle retries - Pass AbortController signal through to fetch for proper cleanup
cancelQueriesis critical in optimistic updates - prevents background refetches from overwriting optimistic updates- Context returned from
onMutateis passed toonErrorandonSettledfor rollback state onSettledruns after both success AND failure - use for final reconciliation- For high-stakes or audit-trail systems, use pessimistic updates instead
- Multiple cache entries require coordinated snapshot and rollback
Implementation Patterns and Pitfalls
State Synchronization
Anti-Pattern: Copying Query Data to useState
Problem: Copying TanStack Query data into useState creates two sources of truth. TanStack Query updates its cache in background (refetches, invalidations, optimistic updates). If you've copied data into useState, local copy doesn't know about these updates. UI shows stale data and user thinks their save didn't work.
❌ Incorrect: synchronizing to state
function Component() {
const { data } = useTracks();
const [localData, setLocalData] = useState(data);
useEffect(() => {
setLocalData(data);
}, [data]);
// Background refetch updates 'data' but 'localData' is stale
}Why This Fails: Background refetches, retries, and cache invalidations all update query data without component knowledge. Copying to useState creates stale local copy, causing "my save didn't work" bugs.
✅ Correct Pattern: Use Query Data Directly
function Component() {
const { data } = useTracks();
const processedData = useMemo(() => transform(data), [data]);
// Single source of truth - automatically updates
}When to Use: Always use query data directly or derive with useMemo. Never copy to useState.
Observer Optimization
Anti-Pattern: Query in Every List Item
Problem: Each useSuspenseQuery call registers an observer on cache entry. List of 200 items, each calling useTrack(id), creates 200 observers. Every time any track query updates, TanStack Query iterates all 200 to check if they need to re-render. If each item has unique query key, you're also making 200 separate network requests instead of one.
❌ Incorrect: query in every list item
function TrackList({ trackIds }) {
return trackIds.map(id => <TrackItem key={id} trackId={id} />);
}
function TrackItem({ trackId }) {
const { data } = useTrack(trackId); // Called N times - N observers, N requests
return <div>{data.name}</div>;
}Why This Fails: Creates O(n) network requests and O(n) observer iteration overhead on every cache update.
✅ Correct Pattern: Single Query in Parent
function TrackList({ trackIds }) {
const { data: tracks } = useAllTracks(); // One query, one observer, one request
return trackIds.map(id => {
const track = tracks.find(t => t.id === id);
return <TrackItem key={id} track={track} />;
});
}
function TrackItem({ track }) {
return <div>{track.name}</div>;
}When to Use: Hoist queries to parent components and pass data as props. One query with one observer is almost always better than N queries with N observers when data comes from same source. See fundamentals.md#observer-performance-impact for detailed thresholds.
Query Key Stability
Anti-Pattern: Unstable Query Keys
Problem: Query keys that change between renders with same props create infinite cache entries and duplicate requests.
❌ Incorrect: unstable keys
// New array instance on every render
queryKey: ['tracks', ...trackIds]
// Temporal value creates infinite unique keys
queryKey: ['events', Date.now()]
// Object without stable serialization
queryKey: ['items', filters] // New object reference each renderWhy This Fails: TanStack Query hashes keys to identify cache entries. Unstable keys create new cache entries on every render, bypassing cache and making duplicate requests.
✅ Correct Pattern: Stable, Deterministic Keys
// Deterministic serialization
queryKey: ['tracks', trackIds.sort().join(',')]
// Stable identifier instead of temporal value
queryKey: ['events', { since: lastEventId }]
// Stable object serialization
queryKey: ['items', JSON.stringify(filters)]When to Use: Always ensure query keys are deterministic. Sort arrays before joining, use stable identifiers instead of temporal values, serialize objects consistently. See query-keys.md for factory patterns.
Dependent Queries
Anti-Pattern: Missing Enabled Guards
Problem: Dependent queries without enabled guards fire with undefined parameters, creating garbage cache entries and wasted requests.
❌ Incorrect: no enabled guard
// data-access/tracks/client.ts
export function useUserTracks(userId: string | undefined) {
return useSuspenseQuery({
queryKey: keys.userTracks(userId!),
queryFn: () => fetchUserTracks(userId!),
// No enabled guard - fires immediately with undefined
});
}Why This Fails: 1. Creates garbage cache entry ['tracks', undefined] 2. Makes wasted network request with invalid parameter 3. When userId arrives, fires again with ['tracks', 'actual-id'] 4. Now two cache entries exist
✅ Correct Pattern 1: Component Composition (Preferred)
export function useUserTracks(userId: string) {
return useSuspenseQuery({ // No undefined possible
queryKey: keys.userTracks(userId),
queryFn: () => fetchUserTracks(userId),
});
}
// Component only mounts when userId exists
function UserProfile() {
const { data: user } = useUser();
return (
<div>
<h1>{user.name}</h1>
<Suspense fallback={<div>Loading tracks...</div>}>
<UserTracks userId={user.id} />
</Suspense>
</div>
);
}
function UserTracks({ userId }: { userId: string }) {
const { data: tracks } = useUserTracks(userId); // userId guaranteed to exist
return <ul>{tracks.map(t => <li key={t.id}>{t.name}</li>)}</ul>;
}✅ Correct Pattern 2: Enabled Guard
export function useUserTracks(userId: string | undefined) {
return useQuery({ // useQuery (not Suspense) for conditional fetching
queryKey: keys.userTracks(userId!),
queryFn: () => fetchUserTracks(userId!),
enabled: Boolean(userId), // Don't run until we have user ID
});
}
// Component usage
function Component() {
const { data: user } = useUser();
const { data: tracks } = useUserTracks(user?.id);
}When to Use: Prefer component composition with useSuspenseQuery for cleaner code. Use enabled guards with useQuery when component composition isn't feasible.
Query Cancellation
Anti-Pattern: Missing AbortController Signal
Problem: Without AbortController signal support, unmounted components leave in-flight requests running. Wastes bandwidth and potentially updates stale cache entries after component unmounted.
❌ Incorrect: no signal support
export function useData() {
return useSuspenseQuery({
queryKey: keys.all(),
queryFn: () => $fetch('/api/data'), // No signal
});
}Why This Fails: When component unmounts or query key changes, fetch continues running. Wastes network resources and may update cache after user navigated away.
✅ Correct Pattern: Pass Signal Through
export function useData() {
return useSuspenseQuery({
queryKey: keys.all(),
queryFn: ({ signal }) => $fetch('/api/data', { signal }),
});
}When to Use: Always pass AbortController signal to fetch requests. TanStack Query provides the signal and aborts when component unmounts, query key changes, or query is manually cancelled.
Custom Hooks and Closures
Anti-Pattern: Inline Query Definitions
Problem: Inline query definitions where queryFn references component-scope variables capture whatever value variable had at render time. When query key changes and triggers refetch, queryFn still uses old closure value.
❌ Incorrect: inline with closure bug
function Component({ trackId }) {
const userId = useUserId();
const { data } = useSuspenseQuery({
queryKey: keys.detail(trackId),
queryFn: () => fetchTrack(trackId, userId), // Captures userId at render time
});
// When trackId changes, queryFn still uses old userId
}Why This Fails: Closure captures stale variables. When query key changes and triggers refetch, queryFn uses the captured value from original render, not current value.
✅ Correct Pattern: Custom Hook with Fresh Parameters
// data-access/tracks/client.ts
export function useTrack(id: string, userId: string) {
return useSuspenseQuery({
queryKey: keys.detail(id),
queryFn: () => fetchTrack(id, userId), // Fresh values on each render
});
}
// Component
function Component({ trackId }) {
const userId = useUserId();
const { data } = useTrack(trackId, userId);
}When to Use: Always wrap queries in custom hooks. Benefits:
- Prevents closure bugs where queryFn captures stale variables
- Centralized query configuration
- Consumers don't need to know query keys
- Easy to update query options in one place
- Better testability
Data Transformation
Pattern: Use select Option
Use select option instead of useMemo for data transformation:
❌ Incorrect: transform in component
function Component() {
const { data } = useItems();
const sorted = useMemo(
() => data.sort((a, b) => a.timestamp - b.timestamp),
[data]
);
}✅ Correct: transform in select within custom hook
// data-access/items/client.ts
const selectSorted = (items: Item[]) => items.sort((a, b) => a.timestamp - b.timestamp);
export function useItemsSorted() {
return useSuspenseQuery({
queryKey: keys.all(),
queryFn: fetchItems,
select: selectSorted, // Stable reference — won't re-run unless data changes
});
}
// Component usage
function Component() {
const { data } = useItemsSorted();
}When to Use: select only runs when data exists (no undefined checks with useSuspenseQuery) and sits next to query definition.
Warning: For datasets >1000 items with frequent updates, select causes double structural sharing overhead. See fundamentals.md#select-option-double-overhead.
Memoization: Stabilize select Function References
select re-executes only when the function reference changes or the underlying data changes. Inline arrow functions create a new reference on every render, causing select to re-run even when data is unchanged — defeating its render-optimization purpose.
❌ Incorrect: inline select re-runs on every render
export function useTodoCount() {
// New function reference each render → select re-runs even when todos are unchanged
return useTodos({ select: (data) => data.length });
}✅ Correct Option 1: extract to a stable module-level variable (preferred with custom hooks)
const selectTodoCount = (data: Todo[]) => data.length;
export function useTodoCount() {
return useTodos({ select: selectTodoCount }); // Same reference every render
}✅ Correct Option 2: useCallback when selector depends on runtime values
export function useTodosByStatus(status: string) {
const selectByStatus = useCallback(
(data: Todo[]) => data.filter(t => t.status === status),
[status]
);
return useTodos({ select: selectByStatus });
}Rule: Prefer extraction to a stable module-level variable — it aligns naturally with the custom hook pattern and has zero runtime cost. Use useCallback only when the selector closes over runtime values that can change.
Errors: select Only Runs on Successful Data
select is called exclusively on successfully cached data. It is never invoked when the query is in an error state. Do not use select to throw errors, validate responses, or handle failure cases — the queryFn is the authoritative source for errors.
❌ Incorrect: validation/error logic in select
export function useSafeItems() {
return useSuspenseQuery({
queryKey: keys.all(),
queryFn: fetchItems,
select: (data) => {
if (!data.length) {
throw new Error('No items found'); // Never reached on fetch failure
}
return data;
},
});
}✅ Correct: errors and validation belong in queryFn
const selectSorted = (items: Item[]) => items.sort((a, b) => a.timestamp - b.timestamp);
export function useSafeItems() {
return useSuspenseQuery({
queryKey: keys.all(),
queryFn: async () => {
const data = await fetchItems();
if (!data.length) {
throw new Error('No items found'); // Surfaces to error state
}
return data;
},
select: selectSorted, // Pure transformation layer — only runs on success
});
}Rule: select is a pure transformation layer for successful results. Put all validation, error detection, and throwing inside queryFn.
Query Options Per Data Type
Pattern: Tune Configuration Based on Data Characteristics
Don't rely solely on global defaults. Tune based on data characteristics in custom hooks:
// data-access/countries/client.ts
export function useCountries() {
return useSuspenseQuery({
queryKey: keys.all(),
queryFn: fetchCountries,
staleTime: 1000 * 60 * 60, // 1 hour - rarely changes
gcTime: Infinity, // Keep forever
});
}
// data-access/tracks/client.ts
export function useTrack(id: string) {
return useSuspenseQuery({
queryKey: keys.detail(id),
queryFn: () => fetchTrack(id),
staleTime: 1000 * 5, // 5 seconds - real-time
gcTime: 1000 * 30, // 30 seconds
refetchInterval: 1000 * 5, // Poll every 5 seconds
});
}
// data-access/radar/client.ts
export function useRadarContacts() {
return useSuspenseQuery({
queryKey: keys.all(),
queryFn: fetchRadarContacts,
staleTime: 1000 * 2, // 2 seconds
structuralSharing: false, // Skip expensive diffing for large datasets
});
}When to Use: Match staleTime to business requirements. Lookup tables can have 1-hour staleTime. Real-time tracking needs 5-second staleTime with refetchInterval. See SKILL.md for decision matrix.
Query Key Dependencies
Pattern: Treat Query Keys as Dependency Arrays
Query keys should include all variables that affect the fetch:
// data-access/tracks/client.ts
export function useTrack(id: string) {
return useSuspenseQuery({
queryKey: keys.detail(id), // Automatically refetches when id changes
queryFn: () => fetchTrack(id),
});
}
// Component usage
function TrackDetails({ trackId }: { trackId: string }) {
const { data } = useTrack(trackId);
}Important: Query keys are deterministically hashed. These create different cache entries:
['tracks', 1](number)['tracks', '1'](string)
❌ Incorrect: missing dependencies
// data-access/items/client.ts
export function useItems(filters: Filters) {
return useSuspenseQuery({
queryKey: keys.all(), // Missing filters!
queryFn: () => fetchItems(filters),
});
}
// Query doesn't refetch when filters change✅ Correct: include all dependencies
// data-access/items/client.ts
export function useItems(filters: Filters) {
return useSuspenseQuery({
queryKey: keys.list(filters),
queryFn: () => fetchItems(filters),
});
}When to Use: Always include all fetch dependencies in query keys. See query-keys.md for factory patterns.
Important Notes
- Query data is the source of truth - don't copy to useState
- Hoist queries to parent when list items need same data
- Query keys must be deterministic and stable
- Dependent queries need
enabledguards or component composition - Always pass AbortController signal to fetch
- Custom hooks prevent closure bugs and provide encapsulation
- Use component composition with
useSuspenseQueryfor cleaner dependent queries - Tune staleTime/gcTime based on data characteristics, not one-size-fits-all
- Observer counts >50 indicate architectural issues requiring refactoring
Query Client Configuration
Factory Pattern with Request Isolation
Configure a query client factory that creates a new instance per request on the server, and a singleton in the browser.
// configs/query-client/index.ts
import { QueryClient, defaultShouldDehydrateQuery, isServer } from '@tanstack/react-query';
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 20, // 20 seconds
gcTime: 1000 * 60 * 2, // 2 minutes
retry: 3,
refetchOnWindowFocus: false,
},
dehydrate: {
// Include pending queries in dehydration for streaming
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
},
},
});
}
let browserQueryClient: QueryClient | undefined = undefined;
export function getQueryClient() {
if (isServer) {
// Server: always create a new query client per request
return makeQueryClient();
} else {
// Browser: create a singleton query client
browserQueryClient ??= makeQueryClient();
return browserQueryClient;
}
}Why This Pattern?
Request Isolation
Each server request gets its own query client, preventing data leakage between users. Server components can execute concurrently for multiple users - a shared query client would:
- Leak cached data between users (security risk)
- Create race conditions in the cache
- Cause memory leaks as the cache grows indefinitely
Streaming Support
Setting shouldDehydrateQuery to include pending queries enables React to stream promises to the client. You can call prefetch functions without await, and the queries will resolve during streaming.
Browser Optimization
Reuses a single client on the browser to maintain cache across navigations. Client-side navigation should preserve cached data for instant back-button responses.
Critical Anti-Pattern
❌ Incorrect: singleton leaks data between users
const queryClient = new QueryClient();
export default function ServerComponent() {
queryClient.prefetchQuery(...); // Shared across all users!
}✅ Correct: factory creates isolated clients
export default function ServerComponent() {
const queryClient = getQueryClient(); // New client per request
queryClient.prefetchQuery(...);
}Default Options Explained
| Option | Value | Why |
|---|---|---|
| staleTime | 20s | Queries stay fresh for 20 seconds, preventing refetches during normal navigation |
| gcTime | 2min | Inactive queries removed after 2 minutes to free memory |
| retry | 3 | Retry failed queries 3 times with exponential backoff |
| refetchOnWindowFocus | false | Disable automatic refetching when user returns to tab (configure per-query for real-time data) |
Tuning for Different Data Types
// Override defaults for specific queries
useQuery({
queryKey: keys.countries(),
queryFn: fetchCountries,
staleTime: 1000 * 60 * 60, // 1 hour - rarely changes
gcTime: Infinity, // Keep forever
});
useQuery({
queryKey: keys.track(id),
queryFn: () => fetchTrack(id),
staleTime: 1000 * 5, // 5 seconds - real-time tracking
gcTime: 1000 * 30, // Aggressive cleanup
refetchInterval: 1000 * 5, // Poll every 5 seconds
});Streaming Without Await
The shouldDehydrateQuery configuration with pending status enables calling prefetch without blocking:
export async function ServerComponent() {
const queryClient = getQueryClient();
// No need to await - queries will stream to the client
prefetchAll(queryClient);
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ClientComponent />
</HydrationBoundary>
);
}React will stream the pending promises to the client, and they'll resolve during hydration. The client component will receive either completed data or suspending promises.
Query Key Factories
Hierarchical Key Structure
Query keys enable cache management. Factories provide type safety, consistency, and hierarchical invalidation.
// data-access/tracks/keys.ts
type TaggedKey<T extends readonly string[]> = T & { readonly tag: string }
function key<T extends readonly string[]>(parts: T): TaggedKey<T> {
const arr = [...parts] as unknown as TaggedKey<T>
Object.defineProperty(arr, 'tag', { get: () => parts.join(':'), enumerable: false })
return arr
}
export const keys = {
all: () => key(['tracks']),
lists: () => key([...keys.all(), 'list']),
list: (filters: string) => key([...keys.lists(), filters]),
details: () => key([...keys.all(), 'detail']),
detail: (id: string) => key([...keys.details(), id]),
};Type Safety with as const
The as const assertion narrows types for precise invalidation:
// Without as const
const key = ['tracks', id]; // Type: string[]
// With as const
const key = ['tracks', id] as const; // Type: readonly ['tracks', string]TypeScript catches typos at compile time instead of runtime cache misses.
Reusability Across Layers
Use the same factories for TanStack Query and Next.js use cache:
// Server-side with use cache
export async function getOne(id: string) {
'use cache';
cacheTag(keys.detail(id).tag); // .tag serializes key to 'tracks:detail:id'
const rawData = await db.query('SELECT * FROM tracks WHERE id = $1', [id]);
return trackSchema.parse(rawData);
}
// Client-side with TanStack Query
export function useTrack(id: string) {
return useSuspenseQuery({
queryKey: keys.detail(id), // Same factory
queryFn: () => getOne(id),
});
}Cache Invalidation Patterns
Hierarchical keys enable surgical invalidation:
// Invalidate all track-related queries
queryClient.invalidateQueries({ queryKey: keys.all() });
// Invalidate all track lists (preserves detail views)
queryClient.invalidateQueries({ queryKey: keys.lists() });
// Invalidate one specific track
queryClient.invalidateQueries({ queryKey: keys.detail('abc-123') });
// Server-side invalidation uses same hierarchy
revalidateTag(keys.all().tag, 'max'); // Invalidate everything
updateTag(keys.detail(id).tag); // Invalidate one item immediatelyKey Stability Rules
❌ Incorrect: unstable keys create cache thrash
queryKey: ['tracks', ...trackIds] // Array order not guaranteed
queryKey: ['events', sql`timestamp BEFORE ${Date.now()}`] // Infinite unique keys
queryKey: ['tracks', 1] // Type inconsistency - different from ['tracks', '1']✅ Correct: deterministic, stable keys
queryKey: ['tracks', trackIds.sort().join(',')]
queryKey: ['events', { before: eventId }]
queryKey: ['tracks', String(id)]Best Practices
1. Use factories, not inline keys - Centralized definitions prevent typos and enable refactoring 2. Spread array hierarchies - [...keys.all(), 'detail'] maintains invalidation hierarchy 3. Match server and client keys - Same factories for both layers enable unified invalidation 4. Document key segments - Comment what each level represents for maintainability 5. Validate key stability - Test that keys don't change between renders with same props
Example: Complete Domain Keys
// data-access/users/keys.ts
type TaggedKey<T extends readonly string[]> = T & { readonly tag: string }
function key<T extends readonly string[]>(parts: T): TaggedKey<T> {
const arr = [...parts] as unknown as TaggedKey<T>
Object.defineProperty(arr, 'tag', { get: () => parts.join(':'), enumerable: false })
return arr
}
export const keys = {
// Base key for all user queries
all: () => key(['users']),
// List queries with optional filters
lists: () => key([...keys.all(), 'list']),
list: (filters?: { role?: string; status?: string }) =>
key([...keys.lists(), filters ? JSON.stringify(filters) : 'all']),
// Detail queries for individual users
details: () => key([...keys.all(), 'detail']),
detail: (userId: string) => key([...keys.details(), userId]),
// Nested resources
preferences: (userId: string) => key([...keys.detail(userId), 'preferences']),
sessions: (userId: string) => key([...keys.detail(userId), 'sessions']),
};
// Array value examples (for TanStack Query):
// keys.all() -> ['users']
// keys.list({ role: 'admin' }) -> ['users', 'list', '{"role":"admin"}']
// keys.detail('user-123') -> ['users', 'detail', 'user-123']
// keys.preferences('user-123') -> ['users', 'detail', 'user-123', 'preferences']
// .tag examples (for cacheTag / revalidateTag / updateTag):
// keys.all().tag -> 'users'
// keys.detail('user-123').tag -> 'users:detail:user-123'
// keys.preferences('user-123').tag -> 'users:detail:user-123:preferences'
// TanStack Query invalidation:
// queryClient.invalidateQueries({ queryKey: keys.all() }) // Everything
// queryClient.invalidateQueries({ queryKey: keys.lists() }) // All lists
// queryClient.invalidateQueries({ queryKey: keys.detail(id) }) // One user + nested
// queryClient.invalidateQueries({ queryKey: keys.preferences(id) }) // Just preferencesIntegration with Mutations
export function useUpdateUser(userId: string) {
const queryClient = getQueryClient();
return useMutation({
mutationFn: (data: Partial<User>) => updateUser(userId, data),
onSuccess: () => {
// Invalidate this user's detail
queryClient.invalidateQueries({ queryKey: keys.detail(userId) });
// Invalidate all user lists (user might appear in filtered lists)
queryClient.invalidateQueries({ queryKey: keys.lists() });
},
});
}Server-Side TanStack Query Integration
HydrationBoundary Pattern
Prefetch data in server components, dehydrate the cache, and pass to client components:
// features/tracks/server.tsx
import 'server-only';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { getQueryClient } from '~/configs/query-client';
import { prefetchAll } from '~/data-access/tracks/server';
import { TracksClient } from './client';
export async function TracksServer() {
const queryClient = getQueryClient();
// No await needed - queries stream to client
prefetchAll(queryClient);
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<TracksClient />
</HydrationBoundary>
);
}Prefetch Functions
Create prefetch helpers in data-access/*/server.ts:
// data-access/tracks/server.ts
import 'server-only';
import type { QueryClient } from '@tanstack/react-query';
import { keys } from './keys';
export async function getOne(id: string) {
'use cache';
cacheTag(keys.detail(id).tag);
const rawData = await db.query('SELECT * FROM tracks WHERE id = $1', [id]);
return trackSchema.parse(rawData);
}
export function prefetchOne(queryClient: QueryClient, id: string) {
return queryClient.prefetchQuery({
queryKey: keys.detail(id),
queryFn: () => getOne(id),
});
}
export function prefetchAll(queryClient: QueryClient) {
return queryClient.prefetchQuery({
queryKey: keys.all(),
queryFn: () => getAll(),
});
}Streaming Without Await
With shouldDehydrateQuery configured to include pending queries, you can skip await:
❌ Incorrect: blocking server render
export async function ServerComponent() {
const queryClient = getQueryClient();
await prefetchAll(queryClient); // Blocks server response
return <HydrationBoundary state={dehydrate(queryClient)}>...</HydrationBoundary>;
}✅ Correct: streaming with pending promises
export async function ServerComponent() {
const queryClient = getQueryClient();
prefetchAll(queryClient); // No await - streams to client
return <HydrationBoundary state={dehydrate(queryClient)}>...</HydrationBoundary>;
}Queries resolve during streaming. Client components receive either completed data or suspending promises.
Integration with use cache
Server-side data functions use Next.js use cache for server-side caching, then get wrapped in prefetch calls for client hydration:
// Server-side cached function
export async function getAll() {
'use cache';
cacheTag(keys.all().tag);
const rawData = await db.query('SELECT * FROM tracks ORDER BY timestamp DESC');
return trackListSchema.parse(rawData);
}
// Prefetch wrapper for TanStack Query
export function prefetchAll(queryClient: QueryClient) {
return queryClient.prefetchQuery({
queryKey: keys.all(), // Same keys as cacheTag
queryFn: () => getAll(), // Calls use cache function
});
}Two-layer caching: 1. use cache reduces database load (server-side, cross-request) 2. TanStack Query provides client-side state management (per-tab, with refetching)
Client Component Usage
Client components use the hydrated data:
// features/tracks/client.tsx
'use client';
import { useAllTracks } from '~/data-access/tracks/client';
export function TracksClient() {
const { data: tracks } = useAllTracks(); // Already hydrated from server
return (
<ul>
{tracks.map(track => <li key={track.id}>{track.name}</li>)}
</ul>
);
}If prefetchAll was called in the server component, useAllTracks returns immediately with cached data. No duplicate request.
Important Notes
HydrationBoundarymust wrap components that use the prefetched queriesdehydrate(queryClient)serializes cache state to pass through React's component boundary- Each server component should create its own
queryClientviagetQueryClient() - Prefetch functions don't need
awaitwhenshouldDehydrateQueryincludes pending status - Client components using hydrated queries should use
useSuspenseQueryto work with Suspense boundaries