
Tanstack Query Advanced
- 25 installs
- 213 repo stars
- Updated August 4, 2026
- yonatangross/orchestkit
Helps with ai & agent building tasks.
About
tanstack-query-advanced is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- tanstack-query-advanced
- AI & Agent Building
- AI-coding skill
Tanstack Query Advanced by the numbers
- 25 all-time installs (skills.sh)
- Ranked #9,800 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/yonatangross/orchestkit --skill tanstack-query-advancedAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 25 |
|---|---|
| repo stars | ★ 213 |
| Last updated | August 4, 2026 |
| Repository | yonatangross/orchestkit ↗ |
What it does
Helps with ai & agent building tasks.
Files
TanStack Query Advanced
Production patterns for TanStack Query v5 - server state management done right.
Overview
- Infinite scroll / pagination
- Optimistic UI updates
- Prefetching for instant navigation
- Complex cache invalidation
- Dependent/parallel queries
- Mutations with rollback
Core Patterns
1. Infinite Queries (Cursor-Based)
import { useInfiniteQuery } from '@tanstack/react-query';
interface Page {
items: Item[];
nextCursor: string | null;
}
function useInfiniteItems() {
return useInfiniteQuery({
queryKey: ['items'],
queryFn: async ({ pageParam }): Promise<Page> => {
const res = await fetch(`/api/items?cursor=${pageParam ?? ''}`);
return res.json();
},
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
getPreviousPageParam: (firstPage) => firstPage.prevCursor,
});
}
// Component
function ItemList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteItems();
return (
<>
{data?.pages.flatMap((page) => page.items.map((item) => (
<ItemCard key={item.id} item={item} />
)))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : hasNextPage ? 'Load More' : 'No more'}
</button>
</>
);
}2. Optimistic Updates
import { useMutation, useQueryClient } from '@tanstack/react-query';
function useUpdateTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] });
// Snapshot previous value
const previousTodo = queryClient.getQueryData(['todos', newTodo.id]);
// Optimistically update
queryClient.setQueryData(['todos', newTodo.id], newTodo);
// Return context for rollback
return { previousTodo };
},
onError: (err, newTodo, context) => {
// Rollback on error
queryClient.setQueryData(['todos', newTodo.id], context?.previousTodo);
},
onSettled: (data, error, variables) => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: ['todos', variables.id] });
},
});
}3. Prefetching Patterns
// Prefetch on hover
function UserLink({ userId }: { userId: string }) {
const queryClient = useQueryClient();
const prefetchUser = () => {
queryClient.prefetchQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
return (
<Link to={`/users/${userId}`} onMouseEnter={prefetchUser}>
View User
</Link>
);
}
// Prefetch in loader (React Router)
export const loader = (queryClient: QueryClient) => async ({ params }) => {
await queryClient.ensureQueryData({
queryKey: ['user', params.id],
queryFn: () => fetchUser(params.id),
});
return null;
};4. Smart Cache Invalidation
const queryClient = useQueryClient();
// Invalidate exact query
queryClient.invalidateQueries({ queryKey: ['todos', 1] });
// Invalidate all todos queries
queryClient.invalidateQueries({ queryKey: ['todos'] });
// Invalidate with predicate
queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === 'todos' &&
(query.queryKey[1] as Todo)?.status === 'done',
});
// Invalidate and refetch immediately
queryClient.refetchQueries({ queryKey: ['todos'], type: 'active' });
// Remove from cache entirely
queryClient.removeQueries({ queryKey: ['todos', 1] });5. Dependent Queries
function useUserPosts(userId: string) {
// First query
const userQuery = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
// Dependent query - only runs when user is loaded
const postsQuery = useQuery({
queryKey: ['posts', userId],
queryFn: () => fetchUserPosts(userId),
enabled: !!userQuery.data, // Only fetch when user exists
});
return { user: userQuery.data, posts: postsQuery.data };
}6. Parallel Queries
import { useQueries } from '@tanstack/react-query';
function useMultipleUsers(userIds: string[]) {
return useQueries({
queries: userIds.map((id) => ({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
staleTime: 5 * 60 * 1000,
})),
combine: (results) => ({
users: results.map((r) => r.data).filter(Boolean),
pending: results.some((r) => r.isPending),
error: results.find((r) => r.error)?.error,
}),
});
}7. Query Deduplication & Batching
// Configure in QueryClient
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute
gcTime: 1000 * 60 * 5, // 5 minutes (formerly cacheTime)
refetchOnWindowFocus: false,
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
},
},
});8. Suspense Integration
import { useSuspenseQuery } from '@tanstack/react-query';
function UserProfile({ userId }: { userId: string }) {
// This will suspend until data is ready
const { data: user } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
return <div>{user.name}</div>;
}
// Wrap with Suspense
<Suspense fallback={<Skeleton />}>
<UserProfile userId="123" />
</Suspense>9. Mutation State Tracking
import { useMutationState } from '@tanstack/react-query';
function PendingTodos() {
// Track all pending todo mutations
const pendingMutations = useMutationState({
filters: { mutationKey: ['addTodo'], status: 'pending' },
select: (mutation) => mutation.state.variables as Todo,
});
return (
<>
{pendingMutations.map((todo) => (
<TodoItem key={todo.id} todo={todo} isPending />
))}
</>
);
}Configuration Best Practices
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // Data fresh for 1 min
gcTime: 1000 * 60 * 5, // Cache for 5 min
refetchOnWindowFocus: true, // Refetch on tab focus
refetchOnReconnect: true, // Refetch on network reconnect
retry: 3, // Retry failed requests
},
mutations: {
retry: 1,
onError: (error) => toast.error(error.message),
},
},
});Quick Reference
// ✅ Create typed query with queryOptions helper (v5)
const userQueryOptions = (id: string) => queryOptions({
queryKey: ['user', id] as const,
queryFn: () => fetchUser(id),
staleTime: 5 * 60 * 1000,
});
// ✅ Use the query options for consistency
const { data } = useQuery(userQueryOptions(userId));
await queryClient.prefetchQuery(userQueryOptions(userId));
await queryClient.ensureQueryData(userQueryOptions(userId));
// ✅ v5: isPending instead of isLoading (initial load only)
if (isPending) return <Skeleton />;
// ✅ v5: gcTime instead of cacheTime
gcTime: 5 * 60 * 1000,
// ✅ useSuspenseQuery for Suspense integration
const { data } = useSuspenseQuery(userQueryOptions(userId));
// ✅ Selective invalidation
queryClient.invalidateQueries({ queryKey: ['todos'], exact: true });
// ❌ NEVER destructure useQuery result at call site
const { data, isLoading } = useQuery({ queryKey: ['users'] }); // BAD - recreates object
// ❌ NEVER use string keys
useQuery({ queryKey: 'users' }); // BAD - use arrays
// ❌ NEVER store server state in Zustand
const useStore = create((set) => ({ users: [] })); // BAD - use React QueryKey Decisions
| Decision | Option A | Option B | Recommendation |
|---|---|---|---|
| Query key structure | String | Array | Array - supports hierarchy and serialization |
| Cache timing | staleTime | gcTime | Both - staleTime for freshness, gcTime for memory |
| Loading state | isLoading | isPending | isPending (v5) - isLoading includes background refetches |
| Query definition | Inline | queryOptions | queryOptions - reusable for prefetch/loader/useQuery |
| Suspense | useQuery + loading | useSuspenseQuery | useSuspenseQuery for React 18+ Suspense |
| Optimistic updates | setQueryData only | setQueryData + invalidate | Both - optimistic then reconcile |
| Parallel queries | Multiple useQuery | useQueries | useQueries - combined loading/error state |
| Infinite queries | Manual pagination | useInfiniteQuery | useInfiniteQuery - built-in cursor handling |
Anti-Patterns (FORBIDDEN)
// ❌ FORBIDDEN: Storing server state in Zustand/Redux
const useStore = create((set) => ({
users: [], // Server state belongs in React Query!
fetchUsers: async () => {
const users = await api.getUsers();
set({ users }); // Stale data, no background refetch
},
}));
// ❌ FORBIDDEN: String query keys
useQuery({
queryKey: 'todos', // Must be an array!
queryFn: fetchTodos,
});
// ❌ FORBIDDEN: Using deprecated cacheTime (v5)
useQuery({
queryKey: ['todos'],
cacheTime: 5 * 60 * 1000, // WRONG - use gcTime in v5
});
// ❌ FORBIDDEN: Using isLoading for initial state (v5)
// isLoading = isPending && isFetching (includes background refetch)
if (isLoading) return <Skeleton />; // WRONG - use isPending
// ❌ FORBIDDEN: Over-invalidating after mutations
useMutation({
mutationFn: updateTodo,
onSuccess: () => {
queryClient.invalidateQueries(); // Invalidates EVERYTHING!
},
});
// ❌ FORBIDDEN: Forgetting to cancel queries in optimistic updates
useMutation({
onMutate: async (newTodo) => {
// Missing: await queryClient.cancelQueries(...)
const previous = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);
return { previous };
},
});
// ❌ FORBIDDEN: Not returning context from onMutate
useMutation({
onMutate: async (newTodo) => {
const previous = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);
// Missing: return { previous }; // Required for rollback!
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context?.previous); // context is undefined!
},
});
// ❌ FORBIDDEN: Mutating cache data directly
queryClient.setQueryData(['todos'], (old) => {
old.push(newTodo); // WRONG - mutates existing array
return old;
});
// ✅ CORRECT: Return new array
queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);
// ❌ FORBIDDEN: Fetching inside useEffect
useEffect(() => {
fetch('/api/users').then(setUsers); // Use React Query instead!
}, []);Related Skills
zustand-patterns- Client state management (use alongside React Query for server state)form-state-patterns- Form state with React Hook Form (integrate mutation status)msw-mocking- Mock Service Worker for testing queries without networkreact-server-components-framework- RSC hydration with React Query
Capability Details
infinite-queries
Keywords: infinite, pagination, cursor, load more, scroll, pages Solves: Implementing cursor-based pagination with automatic page management
optimistic-updates
Keywords: optimistic, instant, rollback, onMutate, setQueryData, cancel Solves: Showing immediate UI feedback before server confirmation with rollback
prefetching
Keywords: prefetch, hover, preload, ensureQueryData, loader, navigation Solves: Loading data before it's needed for instant navigation
cache-invalidation
Keywords: invalidate, refetch, stale, fresh, gcTime, staleTime, exact Solves: Keeping cache in sync with server after mutations
suspense-integration
Keywords: suspense, useSuspenseQuery, streaming, fallback, boundary Solves: Integrating with React Suspense for declarative loading states
parallel-queries
Keywords: useQueries, parallel, concurrent, combine, batch Solves: Fetching multiple independent queries with combined state
References
references/cache-strategies.md- Cache invalidation patternsscripts/query-hooks-template.ts- Production query hook templatechecklists/tanstack-checklist.md- Implementation checklistexamples/tanstack-examples.md- Real-world usage examples
TanStack Query v5 Implementation Checklist
Comprehensive checklist for production-ready TanStack Query integration.
QueryClient Setup
Configuration
- [ ] QueryClient created with sensible defaults
- [ ] staleTime configured (not left at 0 for all queries)
- [ ] gcTime configured based on cache requirements
- [ ] retry logic configured (with exponential backoff)
- [ ] retryDelay uses exponential backoff:
Math.min(1000 * 2 ** attemptIndex, 30000) - [ ] refetchOnWindowFocus set appropriately (true for real-time, false for static)
- [ ] refetchOnReconnect enabled for network-dependent apps
Provider Setup
- [ ] QueryClientProvider wraps app at root
- [ ] ReactQueryDevtools added (dev only)
- [ ] QueryClient instance created outside component (no re-creation)
// ✅ CORRECT: Created outside component
const queryClient = new QueryClient({ ... });
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
{process.env.NODE_ENV === 'development' && <ReactQueryDevtools />}
</QueryClientProvider>
);
}
// ❌ WRONG: Created inside component
function App() {
const queryClient = new QueryClient(); // Re-created every render!
return <QueryClientProvider client={queryClient}>...</QueryClientProvider>;
}Query Keys
Structure
- [ ] Keys are arrays, not strings
- [ ] Keys follow hierarchy:
[entity, action, params] - [ ] Keys include ALL variables that affect the query result
- [ ] Keys use
as constfor type inference
Query Key Factory
- [ ] Query key factory created for each entity
- [ ] Factory exports used consistently throughout app
// ✅ CORRECT: Query key factory
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: Filters) => [...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: string) => [...userKeys.details(), id] as const,
};Query Options (v5)
queryOptions Helper
- [ ]
queryOptionshelper used for reusable query definitions - [ ] Query options exported for use in hooks, loaders, and prefetch
- [ ] Type inference works correctly with queryOptions
// ✅ CORRECT: Reusable query options
export const userQueryOptions = (id: string) =>
queryOptions({
queryKey: userKeys.detail(id),
queryFn: () => fetchUser(id),
staleTime: 5 * 60 * 1000,
});
// Use in hook
const { data } = useQuery(userQueryOptions(id));
// Use in prefetch
queryClient.prefetchQuery(userQueryOptions(id));
// Use in loader
await queryClient.ensureQueryData(userQueryOptions(id));Queries
Hook Usage
- [ ]
isPendingused for initial loading state (v5, notisLoading) - [ ]
isErroranderrorhandled for error states - [ ]
enabledoption used for dependent queries - [ ]
placeholderDataused for instant UI feedback - [ ]
selectused for data transformation when needed
State Handling
- [ ] Loading skeletons shown during
isPending - [ ] Error boundaries or error UI for
isError - [ ] Empty state handled when
datais empty array/null - [ ] Background refetch indicator for
isFetching && !isPending
// ✅ CORRECT: Complete state handling
function UserProfile({ id }: { id: string }) {
const { data, isPending, isError, error, isFetching } = useUser(id);
if (isPending) return <Skeleton />;
if (isError) return <ErrorMessage error={error} />;
if (!data) return <NotFound />;
return (
<div>
{isFetching && <RefetchIndicator />}
<UserCard user={data} />
</div>
);
}Suspense Integration
useSuspenseQuery
- [ ]
useSuspenseQueryused with Suspense boundaries - [ ] Suspense fallback provides appropriate loading UI
- [ ] Error boundary handles query errors
- [ ] Data is guaranteed non-undefined (no
isPendingcheck needed)
// ✅ CORRECT: Suspense integration
function UserProfile({ id }: { id: string }) {
const { data } = useSuspenseQuery(userQueryOptions(id));
// data is guaranteed to exist!
return <UserCard user={data} />;
}
// Parent component
<ErrorBoundary fallback={<ErrorUI />}>
<Suspense fallback={<Skeleton />}>
<UserProfile id={id} />
</Suspense>
</ErrorBoundary>Mutations
Basic Setup
- [ ]
useMutationhook created for each mutation - [ ]
mutationFnproperly typed - [ ]
onSuccessinvalidates related queries - [ ]
onErrorhandles and displays errors - [ ] Loading state shown during mutation (
isPending)
Optimistic Updates
- [ ]
onMutatecancels outgoing refetches - [ ]
onMutatesnapshots previous value - [ ]
onMutateapplies optimistic update - [ ]
onMutatereturns context for rollback - [ ]
onErrorrolls back to previous value - [ ]
onSettledinvalidates to ensure consistency
// ✅ CORRECT: Full optimistic update pattern
useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] });
const previous = queryClient.getQueryData(['todos', newTodo.id]);
queryClient.setQueryData(['todos', newTodo.id], newTodo);
return { previous }; // MUST return context!
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos', newTodo.id], context?.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});Infinite Queries
Setup
- [ ]
useInfiniteQueryused for paginated data - [ ]
initialPageParamprovided (required in v5) - [ ]
getNextPageParamreturns cursor or null when done - [ ]
getPreviousPageParamfor bi-directional pagination (if needed)
Usage
- [ ]
hasNextPagechecked before callingfetchNextPage - [ ]
isFetchingNextPageused for loading indicator - [ ] Pages flattened for rendering:
data.pages.flatMap(p => p.items)
// ✅ CORRECT: Infinite query with intersection observer
function InfiniteList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['items'],
queryFn: ({ pageParam }) => fetchItems(pageParam),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const observerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
});
if (observerRef.current) observer.observe(observerRef.current);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
<>
{data?.pages.flatMap((page) =>
page.items.map((item) => <Item key={item.id} item={item} />)
)}
<div ref={observerRef}>
{isFetchingNextPage && <Spinner />}
</div>
</>
);
}Cache Invalidation
Strategies
- [ ]
invalidateQueriesused after mutations (notrefetchQueries) - [ ] Query key hierarchy leveraged for selective invalidation
- [ ]
exact: trueused when only exact key should be invalidated - [ ]
predicateused for complex invalidation logic
Best Practices
- [ ] No over-invalidation (don't invalidate everything)
- [ ] Related queries invalidated together
- [ ]
removeQueriesused for deleted entities
// ✅ CORRECT: Selective invalidation
onSuccess: (_, deletedId) => {
// Remove the specific item
queryClient.removeQueries({ queryKey: ['todos', deletedId] });
// Invalidate lists (but not other details)
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
}
// ❌ WRONG: Over-invalidation
onSuccess: () => {
queryClient.invalidateQueries(); // Invalidates EVERYTHING!
}Prefetching
Hover Prefetch
- [ ]
prefetchQuerycalled on mouse enter - [ ] staleTime set to prevent immediate refetch
- [ ] Prefetch uses same queryOptions as the query
Route Prefetch
- [ ] React Router loaders use
ensureQueryData - [ ] Prefetch happens before navigation
- [ ] Cache is warm when component mounts
// ✅ CORRECT: Hover prefetch
function UserLink({ id }: { id: string }) {
const queryClient = useQueryClient();
const prefetch = () => {
queryClient.prefetchQuery(userQueryOptions(id));
};
return (
<Link to={`/users/${id}`} onMouseEnter={prefetch}>
View User
</Link>
);
}Performance
Render Optimization
- [ ] Selectors used when only part of data needed
- [ ]
selectoption used for derived data - [ ] Components split to minimize re-renders
- [ ]
notifyOnChangePropsused if needed (advanced)
Network Optimization
- [ ] staleTime > 0 for data that doesn't change frequently
- [ ]
refetchOnWindowFocus: falsefor static data - [ ]
refetchIntervalonly for truly real-time data - [ ] Deduplication working (multiple components, one request)
Testing
Test Setup
- [ ] New QueryClient created per test (no shared state)
- [ ] Retry disabled in tests:
retry: false - [ ] gcTime set to 0 or Infinity based on test needs
Mocking
- [ ] MSW used for API mocking (recommended)
- [ ] Or: queryFn mocked directly for unit tests
- [ ]
waitForused for async assertions
// ✅ CORRECT: Test setup
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
function renderWithClient(ui: React.ReactElement) {
const queryClient = createTestQueryClient();
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>
);
}TypeScript
Type Safety
- [ ] Query return type inferred from queryFn
- [ ] Error type specified:
useQuery<Data, Error> - [ ] Mutation variables and context typed
- [ ]
as constused for query keys
Generic Patterns
- [ ] Query hooks properly typed
- [ ] No
anytypes in query definitions - [ ] Zod or similar used for runtime validation
v5 Migration Checklist
Breaking Changes
- [ ]
cacheTimerenamed togcTime - [ ]
isLoadingusage reviewed (now meansisPending && isFetching) - [ ]
isPendingused for initial load state - [ ]
useQueryreturns stable object references - [ ]
statusvalues: 'pending' | 'error' | 'success' (not 'loading') - [ ]
initialPageParamrequired for infinite queries - [ ] Callbacks moved from useQuery options to component (if needed)
Removed Features
- [ ]
onSuccess/onError/onSettledremoved from useQuery (use useEffect) - [ ]
isLoadingdoesn't exist for initial state (useisPending) - [ ]
removemethod removed (useremoveQueries)
Documentation
- [ ] Query key structure documented
- [ ] Cache timing decisions documented
- [ ] Mutation patterns documented
- [ ] Error handling strategy documented
TanStack Query Real-World Examples
Production-tested patterns for common use cases.
E-Commerce Product Catalog
Complete product browsing with infinite scroll, filters, and prefetch.
import {
useInfiniteQuery,
useQuery,
useQueryClient,
queryOptions,
infiniteQueryOptions,
} from '@tanstack/react-query';
import { useEffect, useRef, useCallback } from 'react';
// Types
interface Product {
id: string;
name: string;
price: number;
category: string;
image: string;
rating: number;
stock: number;
}
interface ProductFilters {
category?: string;
minPrice?: number;
maxPrice?: number;
sortBy?: 'price' | 'rating' | 'name';
sortOrder?: 'asc' | 'desc';
}
interface ProductsPage {
items: Product[];
nextCursor: string | null;
total: number;
}
// Query key factory
export const productKeys = {
all: ['products'] as const,
lists: () => [...productKeys.all, 'list'] as const,
list: (filters?: ProductFilters) => [...productKeys.lists(), filters] as const,
details: () => [...productKeys.all, 'detail'] as const,
detail: (id: string) => [...productKeys.details(), id] as const,
};
// Query options
export const productQueryOptions = (id: string) =>
queryOptions({
queryKey: productKeys.detail(id),
queryFn: async () => {
const res = await fetch(`/api/products/${id}`);
if (!res.ok) throw new Error('Product not found');
return res.json() as Promise<Product>;
},
staleTime: 5 * 60 * 1000, // Products rarely change
});
export const productsInfiniteOptions = (filters?: ProductFilters) =>
infiniteQueryOptions({
queryKey: productKeys.list(filters),
queryFn: async ({ pageParam }) => {
const params = new URLSearchParams();
if (pageParam) params.set('cursor', pageParam);
if (filters?.category) params.set('category', filters.category);
if (filters?.minPrice) params.set('minPrice', String(filters.minPrice));
if (filters?.maxPrice) params.set('maxPrice', String(filters.maxPrice));
if (filters?.sortBy) params.set('sortBy', filters.sortBy);
if (filters?.sortOrder) params.set('sortOrder', filters.sortOrder);
const res = await fetch(`/api/products?${params}`);
return res.json() as Promise<ProductsPage>;
},
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
staleTime: 1 * 60 * 1000,
});
// Hooks
export function useProduct(id: string) {
return useQuery(productQueryOptions(id));
}
export function useProducts(filters?: ProductFilters) {
return useInfiniteQuery(productsInfiniteOptions(filters));
}
// Prefetch hook
export function usePrefetchProduct() {
const queryClient = useQueryClient();
return useCallback(
(id: string) => {
queryClient.prefetchQuery(productQueryOptions(id));
},
[queryClient]
);
}
// Component
function ProductGrid({ filters }: { filters?: ProductFilters }) {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isPending,
} = useProducts(filters);
const prefetchProduct = usePrefetchProduct();
const observerRef = useRef<HTMLDivElement>(null);
// Infinite scroll with IntersectionObserver
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ rootMargin: '100px' }
);
if (observerRef.current) observer.observe(observerRef.current);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
if (isPending) return <ProductGridSkeleton />;
const products = data?.pages.flatMap((page) => page.items) ?? [];
return (
<div className="grid grid-cols-4 gap-4">
{products.map((product) => (
<Link
key={product.id}
to={`/products/${product.id}`}
onMouseEnter={() => prefetchProduct(product.id)}
className="group"
>
<ProductCard product={product} />
</Link>
))}
<div ref={observerRef} className="col-span-4 h-20 flex justify-center">
{isFetchingNextPage && <Spinner />}
</div>
</div>
);
}Shopping Cart with Optimistic Updates
Cart management with instant UI feedback and error recovery.
import {
useMutation,
useQuery,
useQueryClient,
queryOptions,
} from '@tanstack/react-query';
interface CartItem {
id: string;
productId: string;
name: string;
price: number;
quantity: number;
image: string;
}
interface Cart {
items: CartItem[];
subtotal: number;
tax: number;
total: number;
}
// Query options
export const cartQueryOptions = queryOptions({
queryKey: ['cart'],
queryFn: async () => {
const res = await fetch('/api/cart');
return res.json() as Promise<Cart>;
},
staleTime: 0, // Always fetch fresh cart
});
// Hooks
export function useCart() {
return useQuery(cartQueryOptions);
}
export function useAddToCart() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
productId,
quantity,
}: {
productId: string;
quantity: number;
}) => {
const res = await fetch('/api/cart/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId, quantity }),
});
if (!res.ok) throw new Error('Failed to add item');
return res.json() as Promise<Cart>;
},
// Optimistic update
onMutate: async ({ productId, quantity }) => {
await queryClient.cancelQueries({ queryKey: ['cart'] });
const previousCart = queryClient.getQueryData<Cart>(['cart']);
// Get product details from cache or make educated guess
const product = queryClient.getQueryData<Product>(['products', productId]);
if (previousCart && product) {
const existingItem = previousCart.items.find(
(item) => item.productId === productId
);
let updatedItems: CartItem[];
if (existingItem) {
updatedItems = previousCart.items.map((item) =>
item.productId === productId
? { ...item, quantity: item.quantity + quantity }
: item
);
} else {
updatedItems = [
...previousCart.items,
{
id: `temp-${Date.now()}`,
productId,
name: product.name,
price: product.price,
quantity,
image: product.image,
},
];
}
const subtotal = updatedItems.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
const tax = subtotal * 0.1;
queryClient.setQueryData<Cart>(['cart'], {
items: updatedItems,
subtotal,
tax,
total: subtotal + tax,
});
}
return { previousCart };
},
onError: (err, variables, context) => {
if (context?.previousCart) {
queryClient.setQueryData(['cart'], context.previousCart);
}
toast.error('Failed to add item to cart');
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['cart'] });
},
});
}
export function useUpdateCartQuantity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
itemId,
quantity,
}: {
itemId: string;
quantity: number;
}) => {
const res = await fetch(`/api/cart/items/${itemId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ quantity }),
});
if (!res.ok) throw new Error('Failed to update quantity');
return res.json() as Promise<Cart>;
},
onMutate: async ({ itemId, quantity }) => {
await queryClient.cancelQueries({ queryKey: ['cart'] });
const previousCart = queryClient.getQueryData<Cart>(['cart']);
if (previousCart) {
const updatedItems =
quantity === 0
? previousCart.items.filter((item) => item.id !== itemId)
: previousCart.items.map((item) =>
item.id === itemId ? { ...item, quantity } : item
);
const subtotal = updatedItems.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
const tax = subtotal * 0.1;
queryClient.setQueryData<Cart>(['cart'], {
items: updatedItems,
subtotal,
tax,
total: subtotal + tax,
});
}
return { previousCart };
},
onError: (err, variables, context) => {
if (context?.previousCart) {
queryClient.setQueryData(['cart'], context.previousCart);
}
toast.error('Failed to update quantity');
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['cart'] });
},
});
}
export function useRemoveFromCart() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (itemId: string) => {
const res = await fetch(`/api/cart/items/${itemId}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error('Failed to remove item');
return res.json() as Promise<Cart>;
},
onMutate: async (itemId) => {
await queryClient.cancelQueries({ queryKey: ['cart'] });
const previousCart = queryClient.getQueryData<Cart>(['cart']);
if (previousCart) {
const updatedItems = previousCart.items.filter(
(item) => item.id !== itemId
);
const subtotal = updatedItems.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
const tax = subtotal * 0.1;
queryClient.setQueryData<Cart>(['cart'], {
items: updatedItems,
subtotal,
tax,
total: subtotal + tax,
});
}
return { previousCart };
},
onError: (err, itemId, context) => {
if (context?.previousCart) {
queryClient.setQueryData(['cart'], context.previousCart);
}
toast.error('Failed to remove item');
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['cart'] });
},
});
}
// Component
function CartPage() {
const { data: cart, isPending } = useCart();
const updateQuantity = useUpdateCartQuantity();
const removeItem = useRemoveFromCart();
if (isPending) return <CartSkeleton />;
if (!cart?.items.length) return <EmptyCart />;
return (
<div className="max-w-4xl mx-auto">
<h1 className="text-2xl font-bold mb-6">Shopping Cart</h1>
<div className="space-y-4">
{cart.items.map((item) => (
<div key={item.id} className="flex items-center gap-4 p-4 border rounded">
<img src={item.image} alt={item.name} className="w-20 h-20 object-cover" />
<div className="flex-1">
<h3 className="font-medium">{item.name}</h3>
<p className="text-gray-600">${item.price.toFixed(2)}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() =>
updateQuantity.mutate({
itemId: item.id,
quantity: item.quantity - 1,
})
}
disabled={updateQuantity.isPending}
>
-
</button>
<span>{item.quantity}</span>
<button
onClick={() =>
updateQuantity.mutate({
itemId: item.id,
quantity: item.quantity + 1,
})
}
disabled={updateQuantity.isPending}
>
+
</button>
</div>
<button
onClick={() => removeItem.mutate(item.id)}
disabled={removeItem.isPending}
className="text-red-500"
>
Remove
</button>
</div>
))}
</div>
<div className="mt-8 p-4 bg-gray-50 rounded">
<div className="flex justify-between">
<span>Subtotal:</span>
<span>${cart.subtotal.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span>Tax:</span>
<span>${cart.tax.toFixed(2)}</span>
</div>
<div className="flex justify-between font-bold text-lg mt-2">
<span>Total:</span>
<span>${cart.total.toFixed(2)}</span>
</div>
</div>
</div>
);
}User Dashboard with Parallel Queries
Dashboard loading multiple data sources in parallel.
import {
useQuery,
useQueries,
useSuspenseQueries,
queryOptions,
} from '@tanstack/react-query';
import { Suspense } from 'react';
interface User {
id: string;
name: string;
email: string;
avatar: string;
}
interface DashboardStats {
totalOrders: number;
totalSpent: number;
loyaltyPoints: number;
}
interface Order {
id: string;
date: string;
status: string;
total: number;
}
interface Notification {
id: string;
message: string;
read: boolean;
createdAt: string;
}
// Query options
export const userQueryOptions = queryOptions({
queryKey: ['user', 'me'],
queryFn: async () => {
const res = await fetch('/api/users/me');
return res.json() as Promise<User>;
},
staleTime: 10 * 60 * 1000,
});
export const dashboardStatsOptions = queryOptions({
queryKey: ['dashboard', 'stats'],
queryFn: async () => {
const res = await fetch('/api/dashboard/stats');
return res.json() as Promise<DashboardStats>;
},
staleTime: 5 * 60 * 1000,
});
export const recentOrdersOptions = queryOptions({
queryKey: ['orders', 'recent'],
queryFn: async () => {
const res = await fetch('/api/orders?limit=5');
return res.json() as Promise<Order[]>;
},
staleTime: 1 * 60 * 1000,
});
export const notificationsOptions = queryOptions({
queryKey: ['notifications'],
queryFn: async () => {
const res = await fetch('/api/notifications?unread=true');
return res.json() as Promise<Notification[]>;
},
staleTime: 30 * 1000, // Check often
});
// Combined hook using useQueries
export function useDashboardData() {
return useQueries({
queries: [
userQueryOptions,
dashboardStatsOptions,
recentOrdersOptions,
notificationsOptions,
],
combine: (results) => ({
user: results[0].data,
stats: results[1].data,
orders: results[2].data,
notifications: results[3].data,
isPending: results.some((r) => r.isPending),
isError: results.some((r) => r.isError),
errors: results.filter((r) => r.error).map((r) => r.error),
}),
});
}
// Suspense version
export function useSuspenseDashboardData() {
return useSuspenseQueries({
queries: [
userQueryOptions,
dashboardStatsOptions,
recentOrdersOptions,
notificationsOptions,
],
combine: (results) => ({
user: results[0].data,
stats: results[1].data,
orders: results[2].data,
notifications: results[3].data,
}),
});
}
// Component (non-Suspense)
function Dashboard() {
const { user, stats, orders, notifications, isPending, isError } =
useDashboardData();
if (isPending) return <DashboardSkeleton />;
if (isError) return <ErrorMessage />;
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<img
src={user?.avatar}
alt={user?.name}
className="w-12 h-12 rounded-full"
/>
<div>
<h1 className="text-2xl font-bold">Welcome back, {user?.name}</h1>
<p className="text-gray-600">{user?.email}</p>
</div>
</div>
{notifications && notifications.length > 0 && (
<div className="relative">
<BellIcon className="w-6 h-6" />
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
{notifications.length}
</span>
</div>
)}
</div>
{/* Stats Grid */}
<div className="grid grid-cols-3 gap-4">
<StatCard label="Total Orders" value={stats?.totalOrders ?? 0} />
<StatCard
label="Total Spent"
value={`$${(stats?.totalSpent ?? 0).toFixed(2)}`}
/>
<StatCard label="Loyalty Points" value={stats?.loyaltyPoints ?? 0} />
</div>
{/* Recent Orders */}
<div>
<h2 className="text-xl font-semibold mb-4">Recent Orders</h2>
<div className="space-y-2">
{orders?.map((order) => (
<div
key={order.id}
className="flex justify-between p-4 border rounded"
>
<div>
<span className="font-medium">Order #{order.id}</span>
<span className="text-gray-500 ml-2">{order.date}</span>
</div>
<div className="flex items-center gap-4">
<StatusBadge status={order.status} />
<span>${order.total.toFixed(2)}</span>
</div>
</div>
))}
</div>
</div>
</div>
);
}
// Component (Suspense version)
function DashboardWithSuspense() {
return (
<ErrorBoundary fallback={<ErrorMessage />}>
<Suspense fallback={<DashboardSkeleton />}>
<DashboardContent />
</Suspense>
</ErrorBoundary>
);
}
function DashboardContent() {
const { user, stats, orders, notifications } = useSuspenseDashboardData();
// Data is guaranteed to exist here!
return (
<div className="p-6 space-y-6">
{/* Same JSX as above, but no null checks needed */}
</div>
);
}Search with Debounce
Search implementation with debounced queries.
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { useState, useDeferredValue } from 'react';
interface SearchResult {
id: string;
title: string;
description: string;
type: 'product' | 'article' | 'user';
}
interface SearchResponse {
results: SearchResult[];
total: number;
}
// Hook with built-in debounce using React 18's useDeferredValue
export function useSearch(query: string) {
const deferredQuery = useDeferredValue(query);
return useQuery({
queryKey: ['search', deferredQuery],
queryFn: async () => {
const res = await fetch(
`/api/search?q=${encodeURIComponent(deferredQuery)}`
);
return res.json() as Promise<SearchResponse>;
},
enabled: deferredQuery.length >= 2, // Only search with 2+ characters
staleTime: 30 * 1000, // Cache results briefly
placeholderData: keepPreviousData, // Keep showing old results while fetching
});
}
// Component
function SearchBox() {
const [query, setQuery] = useState('');
const { data, isPending, isFetching } = useSearch(query);
const isSearching = query.length >= 2;
const showLoading = isFetching && isSearching;
return (
<div className="relative">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products, articles, users..."
className="w-full p-3 border rounded-lg"
/>
{showLoading && (
<div className="absolute right-3 top-3">
<Spinner className="w-5 h-5" />
</div>
)}
{isSearching && data && (
<div className="absolute top-full left-0 right-0 mt-2 bg-white border rounded-lg shadow-lg max-h-96 overflow-auto">
{data.results.length === 0 ? (
<div className="p-4 text-gray-500">No results found</div>
) : (
<ul>
{data.results.map((result) => (
<li key={result.id}>
<Link
to={`/${result.type}s/${result.id}`}
className="block p-4 hover:bg-gray-50"
>
<div className="font-medium">{result.title}</div>
<div className="text-sm text-gray-500">
{result.description}
</div>
<span className="text-xs text-blue-500 capitalize">
{result.type}
</span>
</Link>
</li>
))}
</ul>
)}
{data.total > data.results.length && (
<div className="p-4 border-t text-center">
<Link to={`/search?q=${query}`} className="text-blue-500">
View all {data.total} results
</Link>
</div>
)}
</div>
)}
</div>
);
}Real-Time Notifications with Polling
Notifications with automatic polling and manual refresh.
import {
useQuery,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
interface Notification {
id: string;
type: 'info' | 'warning' | 'success' | 'error';
title: string;
message: string;
read: boolean;
createdAt: string;
}
export function useNotifications() {
return useQuery({
queryKey: ['notifications'],
queryFn: async () => {
const res = await fetch('/api/notifications');
return res.json() as Promise<Notification[]>;
},
refetchInterval: 30 * 1000, // Poll every 30 seconds
refetchIntervalInBackground: false, // Pause when tab hidden
staleTime: 10 * 1000,
});
}
export function useUnreadCount() {
const { data } = useNotifications();
return data?.filter((n) => !n.read).length ?? 0;
}
export function useMarkAsRead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (notificationId: string) => {
const res = await fetch(`/api/notifications/${notificationId}/read`, {
method: 'POST',
});
if (!res.ok) throw new Error('Failed to mark as read');
},
onMutate: async (notificationId) => {
await queryClient.cancelQueries({ queryKey: ['notifications'] });
const previous = queryClient.getQueryData<Notification[]>(['notifications']);
queryClient.setQueryData<Notification[]>(['notifications'], (old) =>
old?.map((n) =>
n.id === notificationId ? { ...n, read: true } : n
)
);
return { previous };
},
onError: (err, notificationId, context) => {
queryClient.setQueryData(['notifications'], context?.previous);
},
});
}
export function useMarkAllAsRead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
const res = await fetch('/api/notifications/read-all', {
method: 'POST',
});
if (!res.ok) throw new Error('Failed to mark all as read');
},
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: ['notifications'] });
const previous = queryClient.getQueryData<Notification[]>(['notifications']);
queryClient.setQueryData<Notification[]>(['notifications'], (old) =>
old?.map((n) => ({ ...n, read: true }))
);
return { previous };
},
onError: (err, variables, context) => {
queryClient.setQueryData(['notifications'], context?.previous);
},
});
}
// Component
function NotificationCenter() {
const { data: notifications, isPending, refetch, isFetching } =
useNotifications();
const markAsRead = useMarkAsRead();
const markAllAsRead = useMarkAllAsRead();
const unreadCount = useUnreadCount();
return (
<div className="w-96 bg-white rounded-lg shadow-xl">
<div className="flex items-center justify-between p-4 border-b">
<h3 className="font-semibold">
Notifications
{unreadCount > 0 && (
<span className="ml-2 px-2 py-0.5 bg-red-500 text-white text-xs rounded-full">
{unreadCount}
</span>
)}
</h3>
<div className="flex gap-2">
<button
onClick={() => refetch()}
disabled={isFetching}
className="text-gray-500 hover:text-gray-700"
>
{isFetching ? <Spinner className="w-4 h-4" /> : <RefreshIcon />}
</button>
{unreadCount > 0 && (
<button
onClick={() => markAllAsRead.mutate()}
disabled={markAllAsRead.isPending}
className="text-sm text-blue-500"
>
Mark all read
</button>
)}
</div>
</div>
<div className="max-h-96 overflow-auto">
{isPending ? (
<NotificationsSkeleton />
) : notifications?.length === 0 ? (
<div className="p-8 text-center text-gray-500">
No notifications
</div>
) : (
notifications?.map((notification) => (
<div
key={notification.id}
className={`p-4 border-b hover:bg-gray-50 cursor-pointer ${
!notification.read ? 'bg-blue-50' : ''
}`}
onClick={() => {
if (!notification.read) {
markAsRead.mutate(notification.id);
}
}}
>
<div className="flex items-start gap-3">
<NotificationIcon type={notification.type} />
<div className="flex-1">
<p className="font-medium">{notification.title}</p>
<p className="text-sm text-gray-600">{notification.message}</p>
<p className="text-xs text-gray-400 mt-1">
{formatRelativeTime(notification.createdAt)}
</p>
</div>
{!notification.read && (
<div className="w-2 h-2 bg-blue-500 rounded-full" />
)}
</div>
</div>
))
)}
</div>
</div>
);
}Form with Mutation Status
Form submission with loading states and error handling.
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const profileSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
bio: z.string().max(500, 'Bio must be 500 characters or less').optional(),
});
type ProfileFormData = z.infer<typeof profileSchema>;
export function useUpdateProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: ProfileFormData) => {
const res = await fetch('/api/users/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || 'Failed to update profile');
}
return res.json();
},
onSuccess: (data) => {
// Update user cache
queryClient.setQueryData(['user', 'me'], data);
toast.success('Profile updated successfully');
},
onError: (error) => {
toast.error(error.message);
},
});
}
// Component
function ProfileForm() {
const updateProfile = useUpdateProfile();
const {
register,
handleSubmit,
formState: { errors, isDirty },
} = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema),
defaultValues: async () => {
const res = await fetch('/api/users/me');
return res.json();
},
});
const onSubmit = (data: ProfileFormData) => {
updateProfile.mutate(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label htmlFor="name" className="block font-medium">
Name
</label>
<input
id="name"
{...register('name')}
className="w-full p-2 border rounded"
/>
{errors.name && (
<p className="text-red-500 text-sm">{errors.name.message}</p>
)}
</div>
<div>
<label htmlFor="email" className="block font-medium">
Email
</label>
<input
id="email"
type="email"
{...register('email')}
className="w-full p-2 border rounded"
/>
{errors.email && (
<p className="text-red-500 text-sm">{errors.email.message}</p>
)}
</div>
<div>
<label htmlFor="bio" className="block font-medium">
Bio
</label>
<textarea
id="bio"
{...register('bio')}
rows={4}
className="w-full p-2 border rounded"
/>
{errors.bio && (
<p className="text-red-500 text-sm">{errors.bio.message}</p>
)}
</div>
{updateProfile.isError && (
<div className="p-4 bg-red-50 text-red-700 rounded">
{updateProfile.error.message}
</div>
)}
<button
type="submit"
disabled={!isDirty || updateProfile.isPending}
className="px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50"
>
{updateProfile.isPending ? 'Saving...' : 'Save Changes'}
</button>
</form>
);
}Cache Strategies & Invalidation Patterns
Comprehensive guide to TanStack Query v5 caching, invalidation, and data synchronization.
Cache Lifecycle
┌─────────────────────────────────────────────────────────────────────────────┐
│ Query Cache Lifecycle │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ staleTime ┌──────────┐ gcTime ┌──────────────┐ │
│ │ FRESH │ ──────────────► │ STALE │ ────────────► │ GARBAGE │ │
│ │ │ (no refetch) │ │ (if unused) │ COLLECTED │ │
│ └──────────┘ └──────────┘ └──────────────┘ │
│ │ │ │
│ │ Component mounts │ Component mounts │
│ ▼ ▼ │
│ Return cached data Return cached data │
│ (no network request) + background refetch │
│ │
└─────────────────────────────────────────────────────────────────────────────┘staleTime vs gcTime
| Setting | Purpose | Default | When to Adjust |
|---|---|---|---|
staleTime | How long data is considered "fresh" | 0 (always stale) | Increase for rarely-changing data |
gcTime | How long unused data stays in memory | 5 minutes | Increase for frequently revisited pages |
staleTime Configuration
// Real-time data (stock prices, notifications)
useQuery({
queryKey: ['stock', symbol],
queryFn: () => fetchStock(symbol),
staleTime: 0, // Always refetch
refetchInterval: 5000, // Poll every 5s
});
// User profile (changes occasionally)
useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000, // Fresh for 5 minutes
});
// Static configuration (rarely changes)
useQuery({
queryKey: ['config'],
queryFn: fetchConfig,
staleTime: 60 * 60 * 1000, // Fresh for 1 hour
});
// Truly static data (never changes)
useQuery({
queryKey: ['countries'],
queryFn: fetchCountries,
staleTime: Infinity, // Never refetch
});gcTime Configuration
// Frequently revisited pages (keep in memory longer)
useQuery({
queryKey: ['dashboard'],
queryFn: fetchDashboard,
gcTime: 30 * 60 * 1000, // Keep 30 minutes after unmount
});
// Large data that shouldn't linger
useQuery({
queryKey: ['reports', year],
queryFn: () => fetchReports(year),
gcTime: 60 * 1000, // Clear 1 minute after unmount
});
// Critical data (keep indefinitely)
useQuery({
queryKey: ['currentUser'],
queryFn: fetchCurrentUser,
gcTime: Infinity, // Never garbage collect
});Query Key Hierarchy
Query keys form a hierarchy. Invalidating a parent invalidates all children.
// Key hierarchy:
// ['todos']
// └── ['todos', 'list']
// └── ['todos', 'list', { filter: 'active' }]
// └── ['todos', 'list', { filter: 'completed' }]
// └── ['todos', 'detail', '1']
// └── ['todos', 'detail', '2']
// Invalidate ALL todo queries (list + all details)
queryClient.invalidateQueries({ queryKey: ['todos'] });
// Invalidate only list queries (not details)
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
// Invalidate specific filter
queryClient.invalidateQueries({
queryKey: ['todos', 'list', { filter: 'active' }]
});
// Invalidate exact key only (not children)
queryClient.invalidateQueries({
queryKey: ['todos', 'list'],
exact: true
});Invalidation Strategies
1. Mutation-Based Invalidation
// ✅ RECOMMENDED: Invalidate related queries after mutation
const createTodo = useMutation({
mutationFn: api.createTodo,
onSuccess: () => {
// Invalidate list to include new item
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
},
});
const updateTodo = useMutation({
mutationFn: api.updateTodo,
onSuccess: (data, variables) => {
// Invalidate specific item + list
queryClient.invalidateQueries({ queryKey: ['todos', 'detail', variables.id] });
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
},
});
const deleteTodo = useMutation({
mutationFn: api.deleteTodo,
onSuccess: (_, id) => {
// Remove from cache entirely
queryClient.removeQueries({ queryKey: ['todos', 'detail', id] });
// Invalidate list
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
},
});2. Optimistic Updates with Reconciliation
const updateTodo = useMutation({
mutationFn: ({ id, ...data }) => api.updateTodo(id, data),
// Step 1: Cancel any outgoing refetches
onMutate: async ({ id, ...updates }) => {
await queryClient.cancelQueries({ queryKey: ['todos', 'detail', id] });
await queryClient.cancelQueries({ queryKey: ['todos', 'list'] });
// Step 2: Snapshot current state
const previousTodo = queryClient.getQueryData(['todos', 'detail', id]);
const previousList = queryClient.getQueryData(['todos', 'list']);
// Step 3: Optimistically update both caches
queryClient.setQueryData(['todos', 'detail', id], (old) =>
old ? { ...old, ...updates } : undefined
);
queryClient.setQueryData(['todos', 'list'], (old) =>
old?.map((todo) =>
todo.id === id ? { ...todo, ...updates } : todo
)
);
// Step 4: Return context for rollback
return { previousTodo, previousList, id };
},
// Step 5: Rollback on error
onError: (err, variables, context) => {
if (context) {
queryClient.setQueryData(['todos', 'detail', context.id], context.previousTodo);
queryClient.setQueryData(['todos', 'list'], context.previousList);
}
},
// Step 6: Always reconcile with server
onSettled: (data, error, { id }) => {
queryClient.invalidateQueries({ queryKey: ['todos', 'detail', id] });
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
},
});3. Predicate-Based Invalidation
// Invalidate todos by status
queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return (
key[0] === 'todos' &&
key[1] === 'list' &&
(key[2] as { filter?: string })?.filter === 'completed'
);
},
});
// Invalidate all queries older than 10 minutes
queryClient.invalidateQueries({
predicate: (query) => {
const dataUpdatedAt = query.state.dataUpdatedAt;
return Date.now() - dataUpdatedAt > 10 * 60 * 1000;
},
});
// Invalidate queries with errors
queryClient.invalidateQueries({
predicate: (query) => query.state.status === 'error',
});4. Type-Based Invalidation
// Invalidate only active queries (currently rendered)
queryClient.invalidateQueries({ type: 'active' });
// Invalidate inactive queries (not rendered but in cache)
queryClient.invalidateQueries({ type: 'inactive' });
// Invalidate all queries
queryClient.invalidateQueries({ type: 'all' }); // Default5. Refetch vs Invalidate
// invalidateQueries: Mark as stale, refetch if active
queryClient.invalidateQueries({ queryKey: ['todos'] });
// - Marks all matching queries as stale
// - Active queries refetch immediately
// - Inactive queries refetch on next mount
// refetchQueries: Force immediate refetch
queryClient.refetchQueries({ queryKey: ['todos'], type: 'active' });
// - Forces refetch regardless of stale state
// - Only refetches specified type
// When to use each:
// invalidateQueries: After mutations (let React Query decide when to refetch)
// refetchQueries: When you need guaranteed fresh data NOWDirect Cache Manipulation
setQueryData
// Update single item
queryClient.setQueryData(['user', userId], (old) =>
old ? { ...old, name: 'New Name' } : undefined
);
// Add item to list
queryClient.setQueryData(['todos', 'list'], (old) =>
old ? [...old, newTodo] : [newTodo]
);
// Update item in list
queryClient.setQueryData(['todos', 'list'], (old) =>
old?.map((todo) =>
todo.id === updatedTodo.id ? updatedTodo : todo
)
);
// Remove item from list
queryClient.setQueryData(['todos', 'list'], (old) =>
old?.filter((todo) => todo.id !== deletedId)
);
// ⚠️ IMPORTANT: Always return new reference
// ❌ BAD: Mutating existing data
queryClient.setQueryData(['todos'], (old) => {
old?.push(newTodo); // Mutation!
return old;
});
// ✅ GOOD: Return new array
queryClient.setQueryData(['todos'], (old) =>
old ? [...old, newTodo] : [newTodo]
);getQueryData & getQueriesData
// Get single query data
const user = queryClient.getQueryData<User>(['user', userId]);
// Get all matching queries
const allTodoQueries = queryClient.getQueriesData<Todo[]>({
queryKey: ['todos']
});
// Returns: Array<[queryKey, data]>
// Check if data exists
const hasUser = queryClient.getQueryData(['user', userId]) !== undefined;
// Get query state (includes status, error, etc.)
const state = queryClient.getQueryState(['user', userId]);
if (state?.status === 'error') {
console.log('Query failed:', state.error);
}removeQueries
// Remove specific query
queryClient.removeQueries({ queryKey: ['todos', 'detail', deletedId] });
// Remove all queries matching prefix
queryClient.removeQueries({ queryKey: ['todos'] });
// Remove inactive queries only
queryClient.removeQueries({ queryKey: ['todos'], type: 'inactive' });Cache Persistence
persist with localStorage
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24, // 24 hours (must be >= maxAge)
},
},
});
const persister = createSyncStoragePersister({
storage: window.localStorage,
key: 'REACT_QUERY_CACHE',
throttleTime: 1000,
});
function App() {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
maxAge: 1000 * 60 * 60 * 24, // 24 hours
dehydrateOptions: {
shouldDehydrateQuery: (query) => {
// Only persist successful queries
return query.state.status === 'success';
},
},
}}
>
<YourApp />
</PersistQueryClientProvider>
);
}Async Persistence (IndexedDB)
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import { get, set, del } from 'idb-keyval';
const persister = createAsyncStoragePersister({
storage: {
getItem: async (key) => await get(key),
setItem: async (key, value) => await set(key, value),
removeItem: async (key) => await del(key),
},
key: 'REACT_QUERY_CACHE',
});Real-Time Data Strategies
Polling
useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
refetchInterval: 30000, // Poll every 30s
refetchIntervalInBackground: false, // Pause when tab hidden
});WebSocket Integration
// Subscribe to WebSocket updates
useEffect(() => {
const ws = new WebSocket('wss://api.example.com/ws');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'TODO_UPDATED') {
// Update cache directly
queryClient.setQueryData(['todos', data.todo.id], data.todo);
// Invalidate list to ensure consistency
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
}
};
return () => ws.close();
}, [queryClient]);Server-Sent Events (SSE)
useEffect(() => {
const eventSource = new EventSource('/api/events');
eventSource.addEventListener('cache-invalidation', (event) => {
const { queryKey } = JSON.parse(event.data);
queryClient.invalidateQueries({ queryKey });
});
return () => eventSource.close();
}, [queryClient]);Common Patterns Matrix
| Scenario | staleTime | gcTime | Refetch Strategy |
|---|---|---|---|
| Real-time (stocks, chat) | 0 | 5min | Poll or WebSocket |
| User data | 5min | 30min | Window focus |
| Product catalog | 1min | 10min | On navigation |
| Static config | Infinity | Infinity | Manual/deploy |
| Search results | 0 | 1min | On input change |
| Dashboard | 30s | 5min | Poll + window focus |
Debugging Tips
// Log all query activity in development
if (process.env.NODE_ENV === 'development') {
queryClient.getQueryCache().subscribe((event) => {
console.log('Query event:', event.type, event.query.queryKey);
});
}
// Inspect query state
const queryCache = queryClient.getQueryCache();
const queries = queryCache.getAll();
queries.forEach((query) => {
console.log({
key: query.queryKey,
state: query.state.status,
dataUpdatedAt: new Date(query.state.dataUpdatedAt),
isStale: query.isStale(),
});
});/**
* Production TanStack Query v5 Hooks Template
*
* Features:
* - Type-safe queries with Zod validation
* - queryOptions for reusable key/fn pairs
* - Optimistic updates with rollback
* - Infinite queries with cursor pagination
* - Suspense-ready hooks
* - React Router loader integration
* - Error handling patterns
*
* Usage:
* 1. Copy this template
* 2. Replace Todo with your entity
* 3. Update API endpoints
* 4. Configure staleTime/gcTime for your use case
*/
import {
useQuery,
useMutation,
useInfiniteQuery,
useSuspenseQuery,
useQueries,
useQueryClient,
queryOptions,
infiniteQueryOptions,
type QueryClient,
type UseQueryOptions,
type UseMutationOptions,
} from '@tanstack/react-query';
import { z } from 'zod';
// ============================================
// Types & Schemas
// ============================================
// Entity schema with Zod for runtime validation
const todoSchema = z.object({
id: z.string().uuid(),
title: z.string().min(1).max(200),
description: z.string().optional(),
completed: z.boolean(),
priority: z.enum(['low', 'medium', 'high']),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
type Todo = z.infer<typeof todoSchema>;
// Mutation input types
const createTodoSchema = todoSchema.omit({
id: true,
createdAt: true,
updatedAt: true,
});
type CreateTodoInput = z.infer<typeof createTodoSchema>;
const updateTodoSchema = todoSchema.partial().required({ id: true });
type UpdateTodoInput = z.infer<typeof updateTodoSchema>;
// Paginated response
interface PaginatedResponse<T> {
items: T[];
nextCursor: string | null;
previousCursor: string | null;
total: number;
}
// List filters
interface TodoFilters {
status?: 'all' | 'active' | 'completed';
priority?: 'low' | 'medium' | 'high';
search?: string;
}
// ============================================
// API Client
// ============================================
class ApiError extends Error {
constructor(
public status: number,
message: string,
public code?: string
) {
super(message);
this.name = 'ApiError';
}
}
const api = {
// List with pagination
getTodos: async (
cursor?: string | null,
filters?: TodoFilters
): Promise<PaginatedResponse<Todo>> => {
const params = new URLSearchParams();
if (cursor) params.set('cursor', cursor);
if (filters?.status && filters.status !== 'all') {
params.set('status', filters.status);
}
if (filters?.priority) params.set('priority', filters.priority);
if (filters?.search) params.set('search', filters.search);
const res = await fetch(`/api/todos?${params}`);
if (!res.ok) {
throw new ApiError(res.status, 'Failed to fetch todos');
}
const data = await res.json();
return {
items: z.array(todoSchema).parse(data.items),
nextCursor: data.nextCursor,
previousCursor: data.previousCursor,
total: data.total,
};
},
// Single item
getTodo: async (id: string): Promise<Todo> => {
const res = await fetch(`/api/todos/${id}`);
if (!res.ok) {
if (res.status === 404) {
throw new ApiError(404, 'Todo not found', 'NOT_FOUND');
}
throw new ApiError(res.status, 'Failed to fetch todo');
}
return todoSchema.parse(await res.json());
},
// Create
createTodo: async (data: CreateTodoInput): Promise<Todo> => {
const validated = createTodoSchema.parse(data);
const res = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(validated),
});
if (!res.ok) {
throw new ApiError(res.status, 'Failed to create todo');
}
return todoSchema.parse(await res.json());
},
// Update
updateTodo: async ({ id, ...data }: UpdateTodoInput): Promise<Todo> => {
const res = await fetch(`/api/todos/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) {
if (res.status === 404) {
throw new ApiError(404, 'Todo not found', 'NOT_FOUND');
}
throw new ApiError(res.status, 'Failed to update todo');
}
return todoSchema.parse(await res.json());
},
// Delete
deleteTodo: async (id: string): Promise<void> => {
const res = await fetch(`/api/todos/${id}`, { method: 'DELETE' });
if (!res.ok) {
throw new ApiError(res.status, 'Failed to delete todo');
}
},
// Bulk operations
bulkUpdateTodos: async (
ids: string[],
updates: Partial<Omit<Todo, 'id' | 'createdAt' | 'updatedAt'>>
): Promise<Todo[]> => {
const res = await fetch('/api/todos/bulk', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids, updates }),
});
if (!res.ok) {
throw new ApiError(res.status, 'Failed to bulk update todos');
}
return z.array(todoSchema).parse(await res.json());
},
};
// ============================================
// Query Keys Factory
// ============================================
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: string) => [...todoKeys.details(), id] as const,
} as const;
// ============================================
// Query Options (Reusable)
// ============================================
// Single todo query options
export const todoQueryOptions = (id: string) =>
queryOptions({
queryKey: todoKeys.detail(id),
queryFn: () => api.getTodo(id),
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
retry: (failureCount, error) => {
// Don't retry on 404
if (error instanceof ApiError && error.status === 404) return false;
return failureCount < 3;
},
});
// Infinite list query options
export const todosInfiniteQueryOptions = (filters?: TodoFilters) =>
infiniteQueryOptions({
queryKey: todoKeys.list(filters),
queryFn: ({ pageParam }) => api.getTodos(pageParam, filters),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
getPreviousPageParam: (firstPage) => firstPage.previousCursor,
staleTime: 1 * 60 * 1000, // 1 minute
gcTime: 5 * 60 * 1000, // 5 minutes
});
// ============================================
// Query Hooks
// ============================================
/**
* Fetch single todo with loading/error states
*/
export function useTodo(id: string) {
return useQuery(todoQueryOptions(id));
}
/**
* Fetch single todo with Suspense (throws promise)
*/
export function useSuspenseTodo(id: string) {
return useSuspenseQuery(todoQueryOptions(id));
}
/**
* Fetch infinite list with cursor pagination
*/
export function useTodosInfinite(filters?: TodoFilters) {
return useInfiniteQuery(todosInfiniteQueryOptions(filters));
}
/**
* Fetch multiple todos in parallel
*/
export function useMultipleTodos(ids: string[]) {
return useQueries({
queries: ids.map((id) => todoQueryOptions(id)),
combine: (results) => ({
data: results.map((r) => r.data).filter((d): d is Todo => d !== undefined),
isPending: results.some((r) => r.isPending),
isError: results.some((r) => r.isError),
errors: results.filter((r) => r.error).map((r) => r.error),
}),
});
}
// ============================================
// Mutation Hooks
// ============================================
/**
* Create todo with cache update
*/
export function useCreateTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.createTodo,
onSuccess: (newTodo) => {
// Add to detail cache
queryClient.setQueryData(todoKeys.detail(newTodo.id), newTodo);
// Invalidate lists to include new item
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
},
onError: (error) => {
console.error('Failed to create todo:', error);
},
});
}
/**
* Update todo with optimistic update and rollback
*/
export function useUpdateTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.updateTodo,
// Optimistic update
onMutate: async (variables) => {
const { id, ...updates } = variables;
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: todoKeys.detail(id) });
await queryClient.cancelQueries({ queryKey: todoKeys.lists() });
// Snapshot previous values
const previousTodo = queryClient.getQueryData<Todo>(todoKeys.detail(id));
const previousLists = queryClient.getQueriesData<PaginatedResponse<Todo>>({
queryKey: todoKeys.lists(),
});
// Optimistically update detail
if (previousTodo) {
queryClient.setQueryData<Todo>(todoKeys.detail(id), {
...previousTodo,
...updates,
updatedAt: new Date().toISOString(),
});
}
// Optimistically update all lists
previousLists.forEach(([queryKey]) => {
queryClient.setQueryData<PaginatedResponse<Todo>>(queryKey, (old) => {
if (!old) return old;
return {
...old,
items: old.items.map((todo) =>
todo.id === id
? { ...todo, ...updates, updatedAt: new Date().toISOString() }
: todo
),
};
});
});
// Return context for rollback
return { previousTodo, previousLists, id };
},
// Rollback on error
onError: (err, variables, context) => {
if (!context) return;
// Restore detail
if (context.previousTodo) {
queryClient.setQueryData(todoKeys.detail(context.id), context.previousTodo);
}
// Restore all lists
context.previousLists.forEach(([queryKey, data]) => {
queryClient.setQueryData(queryKey, data);
});
},
// Always refetch to ensure consistency
onSettled: (data, error, { id }) => {
queryClient.invalidateQueries({ queryKey: todoKeys.detail(id) });
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
},
});
}
/**
* Delete todo with optimistic removal
*/
export function useDeleteTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.deleteTodo,
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: todoKeys.detail(id) });
await queryClient.cancelQueries({ queryKey: todoKeys.lists() });
const previousTodo = queryClient.getQueryData<Todo>(todoKeys.detail(id));
const previousLists = queryClient.getQueriesData<PaginatedResponse<Todo>>({
queryKey: todoKeys.lists(),
});
// Remove from detail cache
queryClient.removeQueries({ queryKey: todoKeys.detail(id) });
// Remove from all lists
previousLists.forEach(([queryKey]) => {
queryClient.setQueryData<PaginatedResponse<Todo>>(queryKey, (old) => {
if (!old) return old;
return {
...old,
items: old.items.filter((todo) => todo.id !== id),
total: old.total - 1,
};
});
});
return { previousTodo, previousLists, id };
},
onError: (err, id, context) => {
if (!context) return;
if (context.previousTodo) {
queryClient.setQueryData(todoKeys.detail(id), context.previousTodo);
}
context.previousLists.forEach(([queryKey, data]) => {
queryClient.setQueryData(queryKey, data);
});
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
},
});
}
/**
* Bulk update todos (e.g., mark all as completed)
*/
export function useBulkUpdateTodos() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ ids, updates }: { ids: string[]; updates: Partial<Todo> }) =>
api.bulkUpdateTodos(ids, updates),
onSuccess: (updatedTodos) => {
// Update each todo in cache
updatedTodos.forEach((todo) => {
queryClient.setQueryData(todoKeys.detail(todo.id), todo);
});
// Invalidate lists
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
},
});
}
/**
* Toggle todo completion with optimistic update
*/
export function useToggleTodo() {
const updateTodo = useUpdateTodo();
return {
...updateTodo,
mutate: (todo: Todo) =>
updateTodo.mutate({
id: todo.id,
completed: !todo.completed,
}),
mutateAsync: (todo: Todo) =>
updateTodo.mutateAsync({
id: todo.id,
completed: !todo.completed,
}),
};
}
// ============================================
// Prefetching Utilities
// ============================================
/**
* Prefetch single todo (for hover prefetch)
*/
export function usePrefetchTodo() {
const queryClient = useQueryClient();
return (id: string) => {
queryClient.prefetchQuery(todoQueryOptions(id));
};
}
/**
* Prefetch todo list (for navigation)
*/
export function usePrefetchTodos() {
const queryClient = useQueryClient();
return (filters?: TodoFilters) => {
queryClient.prefetchInfiniteQuery(todosInfiniteQueryOptions(filters));
};
}
// ============================================
// React Router Loaders
// ============================================
/**
* Loader for todo detail page
*/
export const todoLoader =
(queryClient: QueryClient) =>
async ({ params }: { params: { id: string } }) => {
const { id } = params;
// Return cached data or fetch
await queryClient.ensureQueryData(todoQueryOptions(id));
return { id };
};
/**
* Loader for todo list page
*/
export const todosLoader =
(queryClient: QueryClient) =>
async ({ request }: { request: Request }) => {
const url = new URL(request.url);
const filters: TodoFilters = {
status: (url.searchParams.get('status') as TodoFilters['status']) || 'all',
priority: url.searchParams.get('priority') as TodoFilters['priority'],
search: url.searchParams.get('search') || undefined,
};
await queryClient.ensureInfiniteQueryData(todosInfiniteQueryOptions(filters));
return { filters };
};
// ============================================
// Utility Hooks
// ============================================
/**
* Get cached todo without triggering fetch
*/
export function useCachedTodo(id: string): Todo | undefined {
const queryClient = useQueryClient();
return queryClient.getQueryData<Todo>(todoKeys.detail(id));
}
/**
* Check if todo is being mutated
*/
export function useIsTodoMutating(id: string): boolean {
const queryClient = useQueryClient();
return (
queryClient.isMutating({
mutationKey: ['updateTodo', id],
}) > 0
);
}
// ============================================
// Selector Hooks (Derived Data)
// ============================================
/**
* Get total count from infinite query
*/
export function useTodoCount(filters?: TodoFilters): number | undefined {
const { data } = useTodosInfinite(filters);
return data?.pages[0]?.total;
}
/**
* Get flattened items from infinite query
*/
export function useFlattenedTodos(filters?: TodoFilters): Todo[] {
const { data } = useTodosInfinite(filters);
return data?.pages.flatMap((page) => page.items) ?? [];
}
// ============================================
// QueryClient Default Configuration
// ============================================
export const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 1 * 60 * 1000, // 1 minute default
gcTime: 5 * 60 * 1000, // 5 minutes default
refetchOnWindowFocus: true,
refetchOnReconnect: true,
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
},
mutations: {
retry: 1,
onError: (error) => {
// Global error handler
console.error('Mutation error:', error);
},
},
},
});
// ============================================
// Testing Utilities
// ============================================
/**
* Create a test query client with no retries
*/
export const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
mutations: {
retry: false,
},
},
});
/**
* Helper to set up test data
*/
export const seedTestData = (queryClient: QueryClient, todos: Todo[]) => {
todos.forEach((todo) => {
queryClient.setQueryData(todoKeys.detail(todo.id), todo);
});
queryClient.setQueryData(todoKeys.list(), {
items: todos,
nextCursor: null,
previousCursor: null,
total: todos.length,
});
};