
Tanstack Query
- 100 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
tanstack-query is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- tanstack-query
- AI & Agent Building
- AI-coding skill
Tanstack Query by the numbers
- 100 all-time installs (skills.sh)
- +3 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #4,381 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill tanstack-queryAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 100 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
TanStack Query
Overview
TanStack Query is an async state manager, not a data fetching library. You provide a queryFn that returns a Promise; React Query handles caching, deduplication, background updates, and stale data management.
When to use: Infinite scrolling, offline-first apps, auto-refetching on focus/reconnect, complex cache invalidation, React Native, hybrid server/client apps.
When NOT to use: Purely synchronous state (useState/Zustand), normalized GraphQL caching (Apollo/urql), server-components-only apps (native fetch), simple fetch-and-display (server components).
Quick Reference
| Pattern | API | Key Points |
|---|---|---|
| Basic query | useQuery({ queryKey, queryFn }) | Include params in queryKey |
| Suspense query | useSuspenseQuery(options) | No enabled option allowed |
| Parallel queries | useQueries({ queries, combine }) | Dynamic parallel fetching |
| Dependent query | useQuery({ enabled: !!dep }) | Wait for prerequisite data |
| Query options | queryOptions({ queryKey, queryFn }) | Reusable, type-safe config |
| Basic mutation | useMutation({ mutationFn, onSuccess }) | Invalidate on success |
| Mutation state | useMutationState({ filters, select }) | Cross-component mutation tracking |
| Optimistic update | onMutate -> cancel -> snapshot -> set | Rollback in onError |
| Infinite query | useInfiniteQuery({ initialPageParam }) | initialPageParam required in v5 |
| Prefetch | queryClient.prefetchQuery(options) | Preload on hover/intent |
| Invalidation | queryClient.invalidateQueries({ queryKey }) | Fuzzy-matches by default, active only |
| Cancellation | queryFn: ({ signal }) => fetch(url, { signal }) | Auto-cancel on key change |
| Select transform | select: (data) => data.filter(...) | Structural sharing preserved |
| Skip token | queryFn: id ? () => fetch(id) : skipToken | Type-safe conditional disabling |
| Serial mutations | useMutation({ scope: { id } }) | Same scope ID runs mutations in serial |
v5 Migration Quick Reference
| v4 (Removed) | v5 (Use Instead) |
|---|---|
useQuery(key, fn, opts) | useQuery({ queryKey, queryFn, ...opts }) |
cacheTime | gcTime |
isLoading (no data) | isPending |
keepPreviousData: true | placeholderData: keepPreviousData |
onSuccess/onError on queries | useEffect or mutation callbacks |
useErrorBoundary | throwOnError |
No initialPageParam | initialPageParam required |
Error type unknown | Error type defaults to Error |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
Checking isPending before data | Data-first: check data -> error -> isPending |
| Copying server state to local useState | Use data directly or derived state pattern |
| Creating QueryClient in component | Create once outside component or in useState |
Using refetch() for parameter changes | Include params in queryKey, let it refetch automatically |
| Same key for useQuery and useInfiniteQuery | Use distinct key segments (different cache structures) |
| Inline select without memoization | Extract to stable function or useCallback |
Using catch without re-throwing | Throw errors in queryFn (fetch doesn't reject on 4xx/5xx) |
| Manual generics on useQuery | Type the queryFn return, let inference work |
| Destructuring query for type narrowing | Keep query object intact for proper narrowing |
Using enabled with useSuspenseQuery | Use conditional rendering to mount/unmount component |
| Not awaiting prefetch for SSR | Await prefetchQuery to avoid hydration mismatches |
invalidateQueries not refetching all | Use refetchType: 'all' for inactive queries |
Delegation
- Query pattern discovery: Use
Exploreagent - Cache strategy review: Use
Taskagent - Code review: Delegate to
code-revieweragent
If the tanstack-router skill is available, delegate route loader and preloading patterns to it.If the tanstack-form skill is available, delegate form submission and mutation coordination to it.If the tanstack-table skill is available, delegate server-side table patterns to it.If the tanstack-start skill is available, delegate server functions and SSR data loading to it.If the tanstack-devtools skill is available, delegate query cache debugging and inspection to it.If the tanstack-db skill is available, delegate reactive client-side database and live query patterns to it.If the tanstack-virtual skill is available, delegate list virtualization and infinite scroll rendering to it.If the tanstack-store skill is available, delegate shared client-side reactive state management to it.If the electricsql skill is available, delegate ElectricSQL real-time Postgres sync patterns to it.If the local-first skill is available, delegate local-first architecture decisions and sync engine selection to it.References
- Basic patterns, architecture, and query variants
- Query keys and factory patterns
- Mutations, optimistic updates, and MutationCache
- Cache operations, staleTime vs gcTime, seeding
- Data transformations and select patterns
- Performance optimization with render tracking and structural sharing
- Error handling strategies
- Infinite queries and pagination
- Offline mode and persistence
- WebSocket and real-time integration
- SSR and hydration patterns
- TypeScript patterns
- Testing with MSW and React Testing Library
- Known v5 issues and workarounds
- Caching coordination with Router — single-source caching strategy, disabling Router cache, coordinated configuration
Basic Patterns
Architecture Mental Model
QueryClient
└── QueryCache
└── Query (one per unique queryKey)
└── QueryObserver (one per useQuery call)- QueryClient: Entry point. Created once, passed via
QueryClientProvider. - QueryCache: Stores all Query instances. One per QueryClient.
- Query: Holds data, error, state for a single queryKey. Shared across all observers.
- QueryObserver: Bridges a Query to a component. Multiple components can observe the same Query — they share cached data and deduplication.
When two components call useQuery({ queryKey: ['todos'] }), they create two QueryObservers pointing to the same Query. Only one network request fires.
Basic Query
const { data, isPending, isError, error } = useQuery({
queryKey: ['todos'],
queryFn: async () => {
const res = await fetch('/api/todos');
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
},
});Data-first rendering pattern:
if (data) return <TodoList todos={data} />;
if (isError) return <div>Error: {error.message}</div>;
return <Skeleton />;Dependent Queries
Query B waits for Query A via enabled:
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
const { data: posts } = useQuery({
queryKey: ['users', userId, 'posts'],
queryFn: () => fetchUserPosts(userId),
enabled: !!user,
});Parallel Queries
Static (known at render time)
Multiple useQuery calls in the same component run in parallel automatically:
const users = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
const projects = useQuery({ queryKey: ['projects'], queryFn: fetchProjects });Dynamic (variable number)
Use useQueries for dynamic parallel fetching:
const results = useQueries({
queries: userIds.map((id) => ({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
})),
combine: (results) => ({
data: results.map((r) => r.data),
pending: results.some((r) => r.isPending),
error: results.find((r) => r.error)?.error,
}),
});Parallel Suspense
useSuspenseQueries fetches in parallel without sequential waterfalls:
const [posts, comments] = useSuspenseQueries({
queries: [
{ queryKey: ['posts'], queryFn: fetchPosts },
{ queryKey: ['comments'], queryFn: fetchComments },
],
});Individual useSuspenseQuery calls in the same component cause waterfalls in React 19. Use useSuspenseQueries or prefetch in loaders instead.
Query Cancellation
Pass signal from the query function context to enable automatic cancellation when the queryKey changes or the component unmounts:
useQuery({
queryKey: ['todos', search],
queryFn: async ({ signal }) => {
const res = await fetch(`/api/todos?q=${search}`, { signal });
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
},
});Works with any API that accepts AbortSignal (fetch, axios with signal option, etc.).
queryOptions Helper
Creates a reusable, type-safe query configuration object:
import { queryOptions } from '@tanstack/react-query';
export const todosOptions = queryOptions({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 1000 * 60 * 5,
});
// Reuse everywhere with full type inference
useQuery(todosOptions);
useSuspenseQuery(todosOptions);
await queryClient.prefetchQuery(todosOptions);
await queryClient.ensureQueryData(todosOptions);
queryClient.invalidateQueries({ queryKey: todosOptions.queryKey });setQueryDefaults
Set default options for all queries matching a key prefix:
queryClient.setQueryDefaults(['todos'], {
staleTime: 1000 * 60 * 10,
gcTime: 1000 * 60 * 60,
});Useful for setting staleTime globally per entity type without repeating in every query. Options merge with individual query options (query-level takes precedence).
Prefetching
Preload data before the user navigates:
const queryClient = useQueryClient();
function TodoLink({ id }: { id: string }) {
return (
<Link
to={`/todos/${id}`}
onMouseEnter={() => {
queryClient.prefetchQuery({
queryKey: ['todos', id],
queryFn: () => fetchTodo(id),
});
}}
>
View Todo
</Link>
);
}prefetchQuery is a no-op if fresh data already exists in cache.
Caching Coordination
The Dual Caching Problem
When using TanStack Router with TanStack Query, both have their own cache. Running them simultaneously leads to confusion about which cache is authoritative and potentially different data in each.
// Bad: Both Router and Query caching active
const router = createRouter({
routeTree,
context: { queryClient },
// defaultPreloadStaleTime uses Router's default cache
});
// Good: Disable Router cache, let Query be single source
const router = createRouter({
routeTree,
context: { queryClient },
defaultPreloadStaleTime: 0,
});Cache Comparison
| Feature | Router Cache | Query Cache |
|---|---|---|
| Invalidation | Manual/time-based | Query keys, patterns |
| Background refetch | No | Yes |
| Optimistic updates | No | Yes |
| Mutations | No built-in | Full support |
| DevTools | Limited | Rich debugging |
| Cross-route sharing | Full | Full |
Coordinated Caching Configuration
export function getRouter() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // Fresh for 1 minute
gcTime: 10 * 60 * 1000, // Cache for 10 minutes
refetchOnWindowFocus: true,
retry: 1,
},
},
});
const router = createRouter({
routeTree,
context: { queryClient },
defaultPreload: 'intent',
defaultPreloadStaleTime: 0, // Router defers to Query
scrollRestoration: true,
defaultStructuralSharing: true,
});
setupRouterSsrQueryIntegration({
router,
queryClient,
});
return router;
}Preloading Still Works
export function getRouter() {
const queryClient = new QueryClient();
const router = createRouter({
routeTree,
context: { queryClient },
defaultPreload: 'intent', // Preload on hover
defaultPreloadStaleTime: 0, // Query decides if data is stale
});
setupRouterSsrQueryIntegration({ router, queryClient });
return router;
}
// When user hovers a Link:
// 1. Router triggers preload
// 2. Loader runs ensureQueryData
// 3. Query checks its cache - fresh? skip fetch. stale? refetch.
// 4. User clicks - data already in Query cacheMutation Invalidation with Single Cache
const createPost = useMutation({
mutationFn: submitPost,
onSuccess: () => {
// Invalidate Query cache - the single source
queryClient.invalidateQueries({ queryKey: ['posts'] });
// Router automatically uses updated cache on next navigation
navigate({ to: '/posts' });
},
});Single Source of Truth Rules
1. Query owns the cache. Never store server data in Zustand, Context, or component state alongside Query. If you need derived data, use select in the query hook. 2. Disable Router cache. Set defaultPreloadStaleTime: 0 so Router always defers to Query for freshness decisions. 3. One query key per entity. Avoid duplicating the same data under different query keys. Use query key factories to keep keys consistent. 4. Invalidate, don't manually set. After mutations, prefer invalidateQueries over setQueryData unless you need instant optimistic feedback.
const postKeys = {
all: ['posts'] as const,
lists: () => [...postKeys.all, 'list'] as const,
list: (filters: PostFilters) => [...postKeys.lists(), filters] as const,
details: () => [...postKeys.all, 'detail'] as const,
detail: (id: string) => [...postKeys.details(), id] as const,
};
const updatePost = useMutation({
mutationFn: (data: UpdatePostInput) => api.updatePost(data.id, data),
onSuccess: (_result, variables) => {
queryClient.invalidateQueries({ queryKey: postKeys.detail(variables.id) });
queryClient.invalidateQueries({ queryKey: postKeys.lists() });
},
});Key Points
defaultPreloadStaleTime: 0means "always ask Query"- Query's
staleTime/gcTimecontrols caching behavior - Preloading still works -- just uses Query's cache
- Mutations, optimistic updates, invalidation all work normally
- DevTools show the single authoritative cache state
- Use
setupRouterSsrQueryIntegrationfor SSR hydration
Cache Operations
const queryClient = useQueryClient();
// Invalidate
queryClient.invalidateQueries({ queryKey: ['posts'] });
queryClient.invalidateQueries({ queryKey: ['posts', '123'] });
// Invalidate with exact match
queryClient.invalidateQueries({ queryKey: ['posts'], exact: true });
// Control refetch behavior
queryClient.invalidateQueries({
queryKey: ['posts'],
refetchType: 'active', // 'active' | 'inactive' | 'all' | 'none'
});
// Set data directly
queryClient.setQueryData(['posts', '123'], newPost);
// Prefetch
await queryClient.prefetchQuery(postOptions('456'));staleTime vs gcTime
// staleTime: How long data is considered "fresh"
// Fresh data won't trigger background refetch. Default: 0 (always stale)
// gcTime: How long unused data stays in cache
// After component unmounts, data stays for this duration. Default: 5 minutes
// Static data (rarely changes)
queryOptions({
queryKey: ['categories'],
queryFn: getCategories,
staleTime: 1000 * 60 * 60, // Fresh for 1 hour
gcTime: 1000 * 60 * 60 * 24, // Keep in cache for 24 hours
});
// Frequently updated data
queryOptions({
queryKey: ['notifications'],
queryFn: getNotifications,
staleTime: 1000 * 30, // Fresh for 30 seconds
refetchInterval: 1000 * 60, // Poll every minute
});Background Refetch Patterns
queryOptions({
queryKey: ['data'],
queryFn: fetchData,
refetchOnWindowFocus: true, // Default: true
refetchOnReconnect: true, // Default: true
refetchOnMount: true, // Default: true
refetchInterval: 1000 * 60, // Polling interval
refetchIntervalInBackground: false, // Only poll when focused
});
// Conditional polling
const { data } = useQuery({
queryKey: ['job', jobId],
queryFn: () => getJobStatus(jobId),
refetchInterval: (query) => {
return query.state.data?.status === 'completed' ? false : 1000;
},
});placeholderData vs initialData
| Aspect | placeholderData | initialData |
|---|---|---|
| Level | Observer (component) | Cache (global) |
| Persistence | Never cached | Persisted to cache |
| Refetch Behavior | Always triggers background refetch | Respects staleTime |
| Error Handling | Becomes undefined on failure | Remains available on error |
| Flag | isPlaceholderData: true | No special flag |
// placeholderData - temporary "fake" data while real data loads
const { data, isPlaceholderData } = useQuery({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id),
placeholderData: { id, name: 'Loading...', completed: false },
});
<div className={isPlaceholderData ? 'opacity-50' : ''}>{data?.name}</div>;
// initialData - data "as good as fetched", persisted and respects staleness
useQuery({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id),
initialData: { id, name: 'Loading...', completed: false },
staleTime: 1000 * 60, // Won't refetch for 1 minute!
});
// initialDataUpdatedAt - tell React Query when initial data was last updated
useQuery({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id),
initialData: cachedTodo,
initialDataUpdatedAt: cachedTodo.lastUpdated,
staleTime: 1000 * 60,
});Cache Seeding
Pull approach -- look up existing cache data for detail views:
function useTodo(id: string) {
const queryClient = useQueryClient();
return useQuery({
queryKey: ['todos', 'detail', id],
queryFn: () => fetchTodo(id),
initialData: () => {
const todos = queryClient.getQueryData<Todo[]>(['todos', 'list']);
return todos?.find((todo) => todo.id === id);
},
initialDataUpdatedAt: () => {
return queryClient.getQueryState(['todos', 'list'])?.dataUpdatedAt;
},
});
}Push approach -- populate detail caches when fetching lists:
function useTodos() {
const queryClient = useQueryClient();
return useQuery({
queryKey: ['todos', 'list'],
queryFn: async () => {
const todos = await fetchTodos();
for (const todo of todos) {
queryClient.setQueryData(['todos', 'detail', todo.id], todo);
}
return todos;
},
});
}Data Transformations
Four approaches ranked by recommendation:
1. Backend Transformation (Ideal)
Have your backend return exactly what the frontend needs. No frontend transformation overhead, but may not be feasible with public APIs or shared backends.
2. In the queryFn
Transform immediately after fetching, before caching:
const fetchTodos = async (): Promise<string[]> => {
const response = await fetch('/api/todos');
const data = await response.json();
return data.map((todo: Todo) => todo.name.toUpperCase());
};
useQuery({ queryKey: ['todos'], queryFn: fetchTodos });Runs on every fetch; transformed structure obscures original data in cache.
3. In the Render Function (useMemo)
function useTodosQuery() {
const queryInfo = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
return {
...queryInfo,
data: useMemo(
() => queryInfo.data?.map((todo) => todo.name.toUpperCase()),
[queryInfo.data],
),
};
}Original data preserved in cache, but transformation runs on component re-renders.
4. Using select (Recommended)
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (data) => data.map((todo) => todo.name.toUpperCase()),
});Structural sharing preserves referential identity. Components only re-render when selected data changes. Original data preserved in cache. Inline functions run every render -- requires memoization for expensive transforms.
select Memoization Strategies
Extract to stable function:
const selectUppercaseTodos = (data: Todo[]) =>
data.map((todo) => todo.name.toUpperCase());
function TodoList() {
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: selectUppercaseTodos,
});
}useCallback for prop-dependent selectors:
const selectFiltered = useCallback(
(data: Todo[]) => data.filter((todo) => todo.status === status),
[status],
);
useQuery({ queryKey: ['todos'], queryFn: fetchTodos, select: selectFiltered });Type-Safe Selector Abstractions
Create reusable query options that accept custom selectors:
function postOptions<TData = Post>(id: string, select?: (data: Post) => TData) {
return queryOptions({
queryKey: ['posts', id],
queryFn: () => fetchPost(id),
select,
});
}
useQuery(postOptions('123')); // data: Post | undefined
useQuery(postOptions('123', (data) => data.title)); // data: string | undefinedWhen to Use Each Approach
| Scenario | Recommended Approach |
|---|---|
| Simple display transformation | select option |
| Expensive computation | select with memoization |
| Need original data elsewhere | select (preserves cache) |
| Transformation for multiple consumers | queryFn transformation |
| Backend control available | Backend transformation |
| Depends on component props | select with useCallback |
Error Handling
Three Strategies
1. Direct Error State Checking:
const { data, isError, error } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
if (isError) return <div>Error: {error.message}</div>;Limitation: replaces entire component with error UI, even during background refetch failures when stale data is available.
2. Error Boundaries with throwOnError:
// Boolean: throw all errors
useQuery({ queryKey: ['todos'], queryFn: fetchTodos, throwOnError: true });
// Function: selective error throwing
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
throwOnError: (error) => error.response?.status >= 500,
});Use for critical data where the component cannot render without it.
3. Global QueryCache Callbacks (Recommended for background errors):
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
// Only show toast for background refetches (stale data exists)
if (query.state.data !== undefined) {
toast.error(`Background update failed: ${error.message}`);
}
},
}),
});Triggers once per failed request, not per Observer.
Data-First Error Pattern
Always check for data before showing error UI:
function TodoList() {
const { data, isError, error, isFetching } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
if (data) {
return (
<>
<ul>
{data.map((todo) => (
<li key={todo.id}>{todo.name}</li>
))}
</ul>
{isError && <Banner>Update failed - showing cached data</Banner>}
{isFetching && <Spinner />}
</>
);
}
if (isError) return <div>Error: {error.message}</div>;
return <Skeleton />;
}fetch API Error Handling
The native fetch API does NOT reject on 4xx/5xx status codes. You must throw manually:
const fetchTodos = async (): Promise<Todo[]> => {
const response = await fetch('/api/todos');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
};Common mistake -- using catch without re-throwing returns a successful Promise with undefined, which React Query treats as success.
Retry Configuration
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
retry: 3, // Default for queries
retry: false, // Disable retries
retry: (failureCount, error) => {
if (error.response?.status === 404) return false;
return failureCount < 3;
},
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});Global Mutation Error Handling
const queryClient = new QueryClient({
mutationCache: new MutationCache({
onError: (error, variables, context, mutation) => {
if (mutation.options.onError) return; // Let local handler take precedence
toast.error(`Operation failed: ${error.message}`);
},
}),
});Infinite Queries
Basic Setup with infiniteQueryOptions
import { infiniteQueryOptions, useInfiniteQuery } from '@tanstack/react-query';
const todosInfiniteOptions = infiniteQueryOptions({
queryKey: ['todos', 'infinite'],
queryFn: ({ pageParam }) => fetchTodosPage(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery(todosInfiniteOptions);infiniteQueryOptions is the infinite-query equivalent of queryOptions. It provides type-safe, reusable configuration for useInfiniteQuery, useSuspenseInfiniteQuery, and queryClient.prefetchInfiniteQuery.
Rendering Pages
{
data?.pages.map((page, i) => (
<Fragment key={i}>
{page.items.map((item) => (
<ItemCard key={item.id} item={item} />
))}
</Fragment>
));
}Flattening Pages with select
Transform the nested pages structure into a flat array:
const { data } = useInfiniteQuery({
queryKey: ['todos', 'infinite'],
queryFn: ({ pageParam }) => fetchTodosPage(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
select: (data) => ({
...data,
pages: data.pages.flatMap((page) => page.items),
}),
});
// data.pages is now a flat array of itemsMemory Optimization with maxPages
maxPages limits how many pages are kept in cache. When the user scrolls forward past the limit, old pages are dropped. Requires getPreviousPageParam so dropped pages can be re-fetched when scrolling back (bi-directional pagination):
useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => fetchPosts(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
getPreviousPageParam: (firstPage) => firstPage.prevCursor,
maxPages: 3,
});Without maxPages, infinite queries accumulate all fetched pages in memory and refetch all of them on invalidation. For long lists, this causes memory bloat and slow refetches.
Intersection Observer Auto-Loading
import { useInView } from 'react-intersection-observer';
function InfinitePostList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery(postsInfiniteOptions());
const { ref, inView } = useInView({ threshold: 0 });
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
<>
{data?.pages.map((page, i) => (
<Fragment key={i}>
{page.items.map((post) => (
<PostCard key={post.id} post={post} />
))}
</Fragment>
))}
<div ref={ref} className="h-10">
{isFetchingNextPage && <Spinner />}
</div>
</>
);
}Bidirectional Infinite Scroll
For chat-style UIs where you load older messages upward and newer messages downward:
const { data, fetchNextPage, fetchPreviousPage, hasPreviousPage, hasNextPage } =
useInfiniteQuery({
queryKey: ['messages', chatId],
queryFn: ({ pageParam }) =>
getMessages({
chatId,
cursor: pageParam.cursor,
direction: pageParam.direction,
}),
initialPageParam: { cursor: undefined, direction: 'backward' as const },
getNextPageParam: (lastPage) =>
lastPage.nextCursor
? { cursor: lastPage.nextCursor, direction: 'forward' as const }
: undefined,
getPreviousPageParam: (firstPage) =>
firstPage.prevCursor
? { cursor: firstPage.prevCursor, direction: 'backward' as const }
: undefined,
});Offset-Based Pagination
For traditional page-number pagination (not infinite scroll), use standard useQuery with keepPreviousData:
import { keepPreviousData } from '@tanstack/react-query';
function postsPageOptions(page: number) {
return queryOptions({
queryKey: ['posts', 'paginated', { page }],
queryFn: () => fetchPostsPage(page),
placeholderData: keepPreviousData,
});
}
function PaginatedPosts() {
const [page, setPage] = useState(1);
const { data, isPlaceholderData } = useQuery(postsPageOptions(page));
return (
<div className={isPlaceholderData ? 'opacity-50' : ''}>
{data?.items.map((post) => (
<PostCard key={post.id} post={post} />
))}
<button disabled={page === 1} onClick={() => setPage((p) => p - 1)}>
Previous
</button>
<button
disabled={isPlaceholderData || !data?.hasMore}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
);
}keepPreviousData keeps the old page visible while the new page loads, preventing layout shift.
Prefetching Next Page
Prefetch the next page while the user views the current one:
const queryClient = useQueryClient();
useEffect(() => {
if (!isPlaceholderData && data?.hasMore) {
queryClient.prefetchQuery(postsPageOptions(page + 1));
}
}, [data, isPlaceholderData, page, queryClient]);Known v5 Issues and Workarounds
16 documented issues from v5 migration, SSR/hydration bugs, and common mistakes.
Issue #1: Object Syntax Required
Error: useQuery is not a function or type errors Source: v5 Migration Guide
v5 removed all function overloads, only object syntax works. Always use useQuery({ queryKey, queryFn, ...options }).
Automated migration: Use the remove-overloads codemod to migrate automatically:
npx jscodeshift@latest ./path/to/src/ \
--extensions=ts,tsx \
--parser=tsx \
--transform=./node_modules/@tanstack/react-query/build/codemods/src/v5/remove-overloads/remove-overloads.cjsReview generated code and run prettier/eslint after applying. The codemod cannot infer all cases and will log messages for manual migration.
Issue #2: Query Callbacks Removed
Error: Callbacks don't run, TypeScript errors Source: v5 Breaking Changes
onSuccess, onError, onSettled removed from queries (still work in mutations). Use useEffect for side effects.
Issue #3: Status Loading → Pending
Error: UI shows wrong loading state Source: v5 Migration
status: 'loading' renamed to status: 'pending'. Use isPending for initial load, isLoading now means isPending && isFetching.
Issue #4: cacheTime → gcTime
Error: cacheTime is not a valid option
Renamed to gcTime to better reflect "garbage collection time".
Issue #5: useSuspenseQuery + enabled
Error: Type error, enabled option not available Source: GitHub Discussion #6206
Suspense guarantees data is available, can't conditionally disable. Use conditional rendering instead.
Issue #6: initialPageParam Required
Error: initialPageParam is required type error Source: v5 Migration
v5 requires explicit initialPageParam for infinite queries (v4 passed undefined).
Issue #7: keepPreviousData Removed
Error: keepPreviousData is not a valid option
Replaced with placeholderData: keepPreviousData helper function.
Issue #8: TypeScript Error Type Default
Error: Type errors with error handling
v5 defaults error type to Error (v4 used unknown). If throwing non-Error types, specify error type explicitly or always throw Error objects.
Issue #9: Streaming Server Components Hydration Error
Error: Hydration failed because the initial UI does not match what was rendered on the server Source: GitHub Issue #9642 Affects: v5.82.0+ with streaming SSR (void prefetch pattern)
Race condition where hydrate() resolves synchronously but query.fetch() creates async retryer, causing isFetching/isStale mismatch.
Workarounds:
// Option 1: Await prefetch instead of void
await streamingQueryClient.prefetchQuery({
queryKey: ['data'],
queryFn: getData,
});
// Option 2: Don't render based on fetchStatus with Suspense
const { data } = useSuspenseQuery({ queryKey: ['data'], queryFn: getData });
return <div>{data}</div>; // No conditional on isFetchingStatus: Known issue, being investigated.
Issue #10: useQuery Hydration Error with Prefetching
Error: Text content mismatch during hydration Source: GitHub Issue #9399 Affects: v5.x with server-side prefetching
tryResolveSync detects resolved promises in RSC payload and extracts data synchronously during hydration, bypassing normal pending state.
Fix: Use useSuspenseQuery instead of useQuery for SSR.
Issue #11: refetchOnMount Not Respected for Errored Queries
Error: Queries refetch on mount despite refetchOnMount: false Source: GitHub Issue #10018 Affects: v5.90.16+
Errored queries with no data are always treated as stale (intentional to avoid permanently showing error states).
Fix: Use retryOnMount: false instead of (or in addition to) refetchOnMount: false.
Issue #12: Mutation Callback Signature Breaking Change
Error: TypeScript errors in mutation callbacks Source: GitHub Issue #9660 Affects: v5.89.0+
onMutateResult parameter added between variables and context, and context now includes client (the QueryClient instance). This eliminates the need to close over useQueryClient() in callbacks.
useMutation({
mutationFn: addTodo,
onMutate: (variables, context) => {
context.client.cancelQueries({ queryKey: ['todos'] });
return { previousTodos: context.client.getQueryData(['todos']) };
},
onError: (error, variables, onMutateResult, context) => {
context.client.setQueryData(['todos'], onMutateResult?.previousTodos);
},
onSuccess: (data, variables, onMutateResult, context) => {
context.client.invalidateQueries({ queryKey: ['todos'] });
},
onSettled: (data, error, variables, onMutateResult, context) => {
context.client.invalidateQueries({ queryKey: ['todos'] });
},
});Issue #13: Readonly Query Keys Break Partial Matching
Source: GitHub Issue #9871 Affects: v5.90.8 only (fixed in v5.90.9)
Partial query matching broke TypeScript types for readonly query keys (as const). Fix: Upgrade to v5.90.9+.
Issue #14: useMutationState Type Inference Lost
Source: GitHub Issue #9825 Affects: All v5.x
Fuzzy mutation key matching prevents guaranteed type inference. mutation.state.variables typed as unknown.
Fix: Explicitly cast types in the select callback:
const pendingTodos = useMutationState({
filters: { mutationKey: ['addTodo'], status: 'pending' },
select: (mutation) => mutation.state.variables as Todo,
});Issue #15: Query Cancellation in StrictMode with fetchQuery
Source: GitHub Issue #9798 Affects: Development only (React StrictMode)
StrictMode double mount/unmount cancels queries when last observer unmounts, even if fetchQuery() is running. This is expected dev-only behavior, doesn't affect production.
Issue #16: invalidateQueries Only Refetches Active Queries
Source: GitHub Issue #9531 Affects: All v5.x
invalidateQueries() only refetches "active" queries (currently observed) by default.
Fix: Use refetchType: 'all' to force refetch of inactive queries:
queryClient.invalidateQueries({
queryKey: ['todos'],
refetchType: 'all', // Refetch active AND inactive
});Mutations
Basic Mutation
const mutation = useMutation({
mutationFn: async (newPost: { title: string; body: string }) => {
const res = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(newPost),
});
if (!res.ok) throw new Error('Failed to create post');
return res.json();
},
onSuccess: async (_data, _variables, _onMutateResult, context) => {
await context.client.invalidateQueries({ queryKey: ['posts'] });
},
});
mutation.mutate(data);mutate vs mutateAsync
| Method | Error Handling | Return Value | Use Case |
|---|---|---|---|
mutate() | Handles internally | void | Most cases, use callbacks |
mutateAsync() | Must catch manually | Promise<TData> | Need to await result |
Prefer mutate() with callbacks for cleaner code:
mutation.mutate(data, {
onSuccess: (result) => navigate(`/posts/${result.id}`),
});
try {
const result = await mutation.mutateAsync(data);
navigate(`/posts/${result.id}`);
} catch (error) {
toast.error(error.message);
}Single Argument Limitation
Mutations accept only ONE variable argument. Use objects for multiple values:
mutation.mutate({ id, title, body });Callback Execution Order
1. useMutation.onMutate 2. useMutation.onSuccess/onError 3. useMutation.onSettled 4. mutate.onSuccess/onError 5. mutate.onSettled
If component unmounts, mutate() callbacks may not fire. Place critical logic (like invalidation) in useMutation() callbacks:
const updatePost = useMutation({
mutationFn: updatePostFn,
onSuccess: (_data, _variables, _onMutateResult, context) => {
context.client.invalidateQueries({ queryKey: ['posts'] });
},
});
updatePost.mutate(data, {
onSuccess: () => {
toast.success('Post updated!');
navigate('/posts');
},
});Returning Promises from Callbacks
Return invalidateQueries to maintain loading state during refetch:
const mutation = useMutation({
mutationFn: createPost,
onSuccess: (_data, _variables, _onMutateResult, context) => {
return context.client.invalidateQueries({ queryKey: ['posts'] });
},
});Without return, mutation.isPending becomes false immediately after the mutation succeeds but before queries refetch.
Optimistic Updates with Rollback
const updatePost = useMutation({
mutationFn: (data: { id: string; title: string }) => updatePostFn(data),
onMutate: async (newData, context) => {
await context.client.cancelQueries({ queryKey: ['posts', newData.id] });
const previousPost = context.client.getQueryData(['posts', newData.id]);
context.client.setQueryData(['posts', newData.id], (old) => ({
...old,
...newData,
}));
return { previousPost };
},
onError: (_error, variables, onMutateResult, context) => {
if (onMutateResult?.previousPost) {
context.client.setQueryData(
['posts', variables.id],
onMutateResult.previousPost,
);
}
},
onSettled: (_data, _error, variables, _onMutateResult, context) => {
context.client.invalidateQueries({ queryKey: ['posts', variables.id] });
},
});Simplified Optimistic Updates via useMutationState
No cache manipulation or rollback needed -- render pending mutations directly:
function OptimisticTodoList() {
const { data: todos } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
const addTodo = useMutation({
mutationKey: ['addTodo'],
mutationFn: (newTodo: CreateTodoInput) => api.addTodo(newTodo),
onSettled: (_data, _error, _variables, _onMutateResult, context) => {
context.client.invalidateQueries({ queryKey: ['todos'] });
},
});
const pendingTodos = useMutationState<CreateTodoInput>({
filters: { mutationKey: ['addTodo'], status: 'pending' },
select: (mutation) => mutation.state.variables,
});
return (
<ul>
{todos?.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
{pendingTodos.map((todo, i) => (
<TodoItem key={`pending-${i}`} todo={todo} isPending />
))}
</ul>
);
}useMutationState returns a snapshot array that updates when matching mutations change. Requires mutationKey on the useMutation call to enable filtering.
Concurrent Optimistic Updates
Handle rapid mutations with isMutating():
const updateTodo = useMutation({
mutationFn: updateTodoFn,
onMutate: async (newData, context) => {
await context.client.cancelQueries({ queryKey: ['todos', newData.id] });
const previous = context.client.getQueryData(['todos', newData.id]);
context.client.setQueryData(['todos', newData.id], newData);
return { previous };
},
onError: (_error, variables, onMutateResult, context) => {
context.client.setQueryData(
['todos', variables.id],
onMutateResult?.previous,
);
},
onSettled: (_data, _error, variables, _onMutateResult, context) => {
if (context.client.isMutating({ mutationKey: ['todos'] }) === 1) {
context.client.invalidateQueries({
queryKey: ['todos', variables.id],
});
}
},
});Using isMutating() ensures only the final mutation triggers invalidation, preventing flickering UI from intermediate refetches.
Serial Mutations with scope
Run mutations with the same scope ID in serial (FIFO queue):
const uploadFile = useMutation({
mutationFn: uploadFileFn,
scope: { id: 'file-upload' },
});All mutations sharing scope: { id: 'file-upload' } execute one at a time. Useful for ordered operations like sequential file uploads or dependent API calls.
Automatic Invalidation via MutationCache
Global mutation error/success handling:
const queryClient = new QueryClient({
mutationCache: new MutationCache({
onSuccess: () => {
queryClient.invalidateQueries();
},
onError: (error, _variables, _context, mutation) => {
if (!mutation.options.onError) {
toast.error(`Operation failed: ${error.message}`);
}
},
}),
});Meta-Based Invalidation Tagging
Specify which queries to invalidate per mutation:
const updateLabel = useMutation({
mutationFn: updateLabelFn,
meta: {
invalidates: [['issues'], ['labels']],
},
});
const queryClient = new QueryClient({
mutationCache: new MutationCache({
onSuccess: async (_data, _variables, _context, mutation) => {
const invalidates = mutation.meta?.invalidates as string[][] | undefined;
if (invalidates) {
for (const queryKey of invalidates) {
await queryClient.invalidateQueries({ queryKey });
}
}
},
}),
});Global Loading Indicator
import { useMutationState } from '@tanstack/react-query';
function GlobalSavingIndicator() {
const pendingCount = useMutationState({
filters: { status: 'pending' },
select: (mutation) => mutation.state.status,
}).length;
if (pendingCount === 0) return null;
return <div>Saving {pendingCount} items...</div>;
}Query with Server Functions
import { createServerFn } from '@tanstack/react-start';
const getPosts = createServerFn({ method: 'GET' }).handler(async () => {
return await db.query.posts.findMany();
});
function postsOptions() {
return queryOptions({
queryKey: ['posts'],
queryFn: () => getPosts(),
});
}
export const Route = createFileRoute('/posts')({
loader: async ({ context }) => {
await context.queryClient.ensureQueryData(postsOptions());
},
});Offline Mode and Persistence
Network Mode
Three networkMode settings control fetch behavior when offline:
| Mode | Behavior |
|---|---|
online (default) | Queries pause when offline, resume when online |
always | Queries always fire regardless of network |
offlineFirst | First request always fires, retries pause when offline |
const queryClient = new QueryClient({
defaultOptions: {
queries: { networkMode: 'offlineFirst' },
mutations: { networkMode: 'offlineFirst' },
},
});offlineFirst is ideal for apps with service workers or local-first architectures where the first request may succeed from a local cache.
fetchStatus vs status
Queries have two orthogonal status axes:
| Axis | Values | Meaning |
|---|---|---|
status | pending, error, success | Does the query have data? |
fetchStatus | fetching, paused, idle | Is the queryFn currently running? |
Combined states:
| status | fetchStatus | Meaning |
|---|---|---|
success | fetching | Has data, background refetch in progress |
success | idle | Has data, nothing happening |
pending | fetching | No data yet, first fetch in progress |
pending | paused | No data, fetch paused (offline) |
error | idle | Failed, not retrying |
error | fetching | Failed previously, retrying now |
Use isPaused to detect when a query is waiting for network:
const { data, isPending, isPaused } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
if (isPaused) return <OfflineBanner />;
if (isPending) return <Spinner />;isPaused is true when fetchStatus === 'paused'. This happens when a query wants to fetch but cannot because the device is offline (in online or offlineFirst network mode).
Mutation Persistence
Persist mutations across page reloads so they resume when the app restarts:
queryClient.setMutationDefaults(['addTodo'], {
mutationFn: addTodo,
onMutate: async (variables, context) => {
await context.client.cancelQueries({ queryKey: ['todos'] });
const previous = context.client.getQueryData(['todos']);
context.client.setQueryData(['todos'], (old: Todo[]) => [
...old,
variables,
]);
return { previous };
},
onError: (_error, _variables, onMutateResult, context) => {
if (onMutateResult?.previous) {
context.client.setQueryData(['todos'], onMutateResult.previous);
}
},
retry: 3,
});
const state = dehydrate(queryClient);
localStorage.setItem('queryState', JSON.stringify(state));
const savedState = JSON.parse(localStorage.getItem('queryState') ?? 'null');
if (savedState) {
hydrate(queryClient, savedState);
}
queryClient.resumePausedMutations();Query Cache Persistence
Persist the entire query cache to storage for offline support and faster startup:
import { QueryClient } from '@tanstack/react-query';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24,
staleTime: 1000 * 60 * 5,
},
},
});
const persister = createSyncStoragePersister({
storage: window.localStorage,
key: 'REACT_QUERY_CACHE',
});
function App() {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
maxAge: 1000 * 60 * 60 * 24,
}}
>
<MyApp />
</PersistQueryClientProvider>
);
}Async Persistence with IndexedDB
For larger caches, use IndexedDB instead of localStorage:
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',
});Selective Persistence
Only persist certain queries:
import { persistQueryClient } from '@tanstack/react-query-persist-client';
persistQueryClient({
queryClient,
persister,
dehydrateOptions: {
shouldDehydrateQuery: (query) => {
if (query.queryKey[0] === 'user-session') return false;
if (query.queryKey[0] === 'notifications') return false;
if (query.state.status !== 'success') return false;
return true;
},
},
});Persistence Configuration
| Option | Purpose |
|---|---|
maxAge | Maximum cache age before considered invalid |
buster | String to invalidate cache (use app version) |
dehydrateOptions.shouldDehydrateQuery | Filter which queries to persist |
hydrateOptions.shouldHydrate | Filter which queries to restore |
Use buster with your app version to automatically invalidate persisted caches after deployments:
persistOptions={{
persister,
maxAge: 1000 * 60 * 60 * 24,
buster: BUILD_VERSION,
}}onlineManager
The onlineManager singleton controls how TanStack Query detects network state. In v5, online state defaults to true and updates via browser online/offline events.
| Method | Purpose |
|---|---|
.isOnline() | Check current online state |
.setOnline(boolean) | Manually override online state (useful for testing) |
.subscribe(callback) | Listen to online/offline changes (returns unsubscribe) |
.setEventListener(listener) | Replace default network detection |
import { onlineManager } from '@tanstack/react-query';
import NetInfo from '@react-native-community/netinfo';
onlineManager.setEventListener((setOnline) => {
return NetInfo.addEventListener((state) => {
setOnline(!!state.isConnected);
});
});Async Storage Persister (React Native)
Use @tanstack/query-async-storage-persister with React Native's AsyncStorage:
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
const persister = createAsyncStoragePersister({
storage: AsyncStorage,
throttleTime: 1000,
});| Option | Default | Purpose |
|---|---|---|
storage | (required) | AsyncStorage-compatible |
key | "REACT_QUERY_OFFLINE_CACHE" | Storage key |
throttleTime | 1000 | Minimum ms between saves |
serialize | JSON.stringify | Custom serializer |
deserialize | JSON.parse | Custom deserializer |
useIsRestoring
Returns true while PersistQueryClientProvider is restoring the cache from storage. Queries are blocked from firing until restoration completes.
import { useIsRestoring } from '@tanstack/react-query';
function App() {
const isRestoring = useIsRestoring();
if (isRestoring) return <LoadingScreen />;
return <MainApp />;
}Performance Optimization
Tracked Properties (Default Behavior)
TanStack Query tracks which properties you access and only re-renders when those properties change. This happens automatically:
function TodoCount() {
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
return <div>Total: {data?.length ?? 0}</div>;
}This component only accesses data, so it won't re-render when isFetching or isStale change. Background refetches happen silently without triggering re-renders.
notifyOnChangeProps
Override automatic tracking to explicitly control which properties trigger re-renders:
const { data, error } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
notifyOnChangeProps: ['data', 'error'],
});Now the component only re-renders when data or error change. Changes to isPending, isFetching, isStale, etc. are ignored.
Opt-Out of Tracking
Set notifyOnChangeProps: 'all' to disable smart tracking and re-render on any change:
const query = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
notifyOnChangeProps: 'all',
});Useful when spreading the entire query object or debugging tracking issues.
Destructuring vs Spreading
const { data, error, isPending } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
const query = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});First example tracks data, error, isPending. Second example tracks nothing unless you access properties. If you spread {...query}, use notifyOnChangeProps: 'all' to ensure correct behavior.
Structural Sharing
TanStack Query preserves referential equality for unchanged data. When a background refetch returns identical JSON, existing references remain stable:
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
// If server returns same JSON, `data` reference doesn't change
// useMemo/useCallback dependencies stay stable
const completedCount = useMemo(
() => data?.filter((t) => t.completed).length ?? 0,
[data],
);How It Works
React Query deeply compares new data with cached data. Unchanged objects/arrays keep their old reference:
// Initial fetch
const v1 = [{ id: 1, title: 'Task 1' }];
// Background refetch returns same data
const v2 = [{ id: 1, title: 'Task 1' }];
// React Query detects structural equality
// Returns v1 reference, not v2Limitations
Only works with JSON-compatible data (objects, arrays, primitives). Non-serializable values (Functions, Dates, Maps, Sets) always trigger new references.
Custom Structural Sharing
Provide your own comparison function for non-JSON data:
useQuery({
queryKey: ['data'],
queryFn: fetchDataWithDates,
structuralSharing: (oldData, newData) => {
if (!oldData) return newData;
if (isEqual(oldData, newData)) return oldData;
return newData;
},
});Disable Structural Sharing
For large responses where deep comparison is expensive:
useQuery({
queryKey: ['large-dataset'],
queryFn: fetchLargeDataset,
structuralSharing: false,
});select with Memoization
The select option transforms data before it reaches your component. Structural sharing applies to the transformed result:
const selectUppercaseTodos = (data: Todo[]) =>
data.map((todo) => todo.name.toUpperCase());
function TodoList() {
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: selectUppercaseTodos,
});
}Extracting to a stable function reference prevents re-running the transformation on every render.
Inline select (Anti-pattern)
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (data) => data.map((todo) => todo.name.toUpperCase()),
});This inline arrow function creates a new reference on every render, forcing the selector to re-run even when data hasn't changed.
useCallback for Dynamic Selectors
When the selector depends on props or state:
function FilteredTodos({ status }: { status: string }) {
const selectFiltered = useCallback(
(data: Todo[]) => data.filter((t) => t.status === status),
[status],
);
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: selectFiltered,
});
}Structural Sharing on select Results
React Query applies structural sharing to the output of select:
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (data) => data.filter((todo) => todo.completed),
});If background refetch returns the same list of completed todos, the filtered array reference stays stable even though a new selector function ran.
Global Configuration
Set defaults for all queries:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
notifyOnChangeProps: ['data', 'error'],
structuralSharing: true,
},
},
});Query-level options override global defaults.
When to Optimize
| Scenario | Recommendation |
|---|---|
| Component re-renders too often | Check tracked properties, use select |
| Large dataset causing slow deep | Disable structural sharing |
| Expensive transformation | Use select with stable function |
| Spreading query object | Set notifyOnChangeProps: 'all' |
| Non-JSON data (Dates, custom) | Custom structuralSharing function |
| Selector depends on props | Use useCallback |
| Simple component, few properties | Default tracking is sufficient |
Query Keys
Object-Based Keys
Use objects for named property access instead of positional array indices:
// Array-based (positional - error prone)
const key = ['todos', 'list', status, sorting] as const;
const [, , statusParam, sortingParam] = key; // Easy to misalign
// Object-based (named - safer)
const key = ['todos', 'list', { status, sorting }] as const;
const [, , { status, sorting }] = key; // Self-documentingQuery Function Context
Extract parameters from context instead of closures:
import { type QueryFunctionContext } from '@tanstack/react-query';
const todoKeys = {
list: (filters: { status: string }) => ['todos', 'list', filters] as const,
};
type TodoListKey = ReturnType<typeof todoKeys.list>;
async function fetchTodos({
queryKey,
signal,
}: QueryFunctionContext<TodoListKey>) {
const [, , { status }] = queryKey; // Fully typed!
const response = await fetch(`/api/todos?status=${status}`, { signal });
return response.json();
}
useQuery({
queryKey: todoKeys.list({ status: 'active' }),
queryFn: fetchTodos,
});Benefits: automatic abort signal handling, type-safe parameter extraction, no closure variables to track.
Query Key Factory (TkDodo Pattern)
For granular cache invalidation, separate keys from options:
export const postKeys = {
all: ['posts'] as const,
lists: () => [...postKeys.all, 'list'] as const,
list: (filters: PostFilters) => [...postKeys.lists(), filters] as const,
details: () => [...postKeys.all, 'detail'] as const,
detail: (id: string) => [...postKeys.details(), id] as const,
};
export const postQueries = {
list: (filters: PostFilters) =>
queryOptions({
queryKey: postKeys.list(filters),
queryFn: () => fetchPosts(filters),
}),
detail: (id: string) =>
queryOptions({
queryKey: postKeys.detail(id),
queryFn: () => fetchPost(id),
}),
};
// Granular invalidation
queryClient.invalidateQueries({ queryKey: postKeys.all }); // All posts
queryClient.invalidateQueries({ queryKey: postKeys.lists() }); // All lists
queryClient.invalidateQueries({ queryKey: postKeys.detail('123') }); // One postAlternative: Centralized queryOptions Factory
Best for most use cases -- co-locates keys with query configuration:
export const queries = {
posts: {
all: () =>
queryOptions({
queryKey: ['posts'],
queryFn: getPosts,
}),
detail: (id: string) =>
queryOptions({
queryKey: ['posts', id],
queryFn: () => getPost(id),
staleTime: 1000 * 60 * 5,
}),
},
users: {
current: () =>
queryOptions({
queryKey: ['user', 'current'],
queryFn: getCurrentUser,
staleTime: 1000 * 60 * 10,
}),
},
} as const;
useQuery(queries.posts.detail('123'));
queryClient.invalidateQueries({ queryKey: queries.posts.all().queryKey });When to use each:
- Centralized factory: Most applications, simpler mental model
- Hierarchical key factory: Complex invalidation needs, large-scale applications
Key Colocation Principle
Keep query keys alongside their queries, not in a central file:
src/
├── features/
│ ├── posts/
│ │ ├── queries.ts # postKeys + postQueries
│ │ └── components/
│ └── users/
│ ├── queries.ts # userKeys + userQueries
│ └── components/Modifying a query and its key happens together. Co-location reduces cognitive overhead.
ESLint Rules (@tanstack/eslint-plugin-query)
- exhaustive-deps - Ensures query dependencies are properly included
- stable-query-client - QueryClient must remain stable across renders
- no-rest-destructuring - Prevents problematic rest destructuring
- no-unstable-deps - Flags unstable dependencies causing re-renders
- infinite-query-property-order - Validates property ordering
SSR and Hydration
Core Pattern
Create QueryClient per request on the server. Prefetch data, then wrap with HydrationBoundary to transfer cache to the client:
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query';
async function ServerPage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery(todosQueryOptions);
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Todos />
</HydrationBoundary>
);
}Use useSuspenseQuery (not useQuery) on the client for SSR to avoid hydration mismatches from conditional isLoading rendering.
QueryClient per Request
Never share a QueryClient across requests -- data leaks between users:
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60,
},
},
});
}
let browserQueryClient: QueryClient | undefined;
function getQueryClient() {
if (typeof window === 'undefined') {
return makeQueryClient();
}
if (!browserQueryClient) {
browserQueryClient = makeQueryClient();
}
return browserQueryClient;
}Router Integration: Cache-First Resolution
Return cached data immediately, only fetch if cache is empty:
export const Route = createFileRoute('/posts/$id')({
loader: async ({ context, params }) => {
const { queryClient } = context;
const options = postOptions(params.id);
return (
queryClient.getQueryData(options.queryKey) ??
(await queryClient.fetchQuery(options))
);
},
});ensureQueryData vs prefetchQuery
| Method | Behavior | Returns |
|---|---|---|
prefetchQuery | Fetches, never throws, returns void | void |
ensureQueryData | Returns cached data OR fetches | TData |
fetchQuery | Always fetches, throws on error | TData |
export const Route = createFileRoute('/posts')({
loader: async ({ context }) => {
await context.queryClient.ensureQueryData(postsOptions());
},
});Await as a Control Lever
Control navigation behavior with selective awaiting:
export const Route = createFileRoute('/posts')({
loader: async ({ context }) => {
await context.queryClient.ensureQueryData(postsOptions());
context.queryClient.prefetchQuery(recommendationsOptions());
},
});Awaited queries block navigation until data loads. Non-awaited queries start fetching but allow immediate navigation with loading states via Suspense.
React 19 Suspense Considerations
React 19 no longer pre-renders siblings when one suspends -- causes sequential waterfalls:
<Suspense fallback={<Loading />}>
<Posts />
<Comments />
</Suspense>Posts suspends first, then Comments waits for Posts to complete before starting its fetch.
Solution 1: Prefetch in loaders to avoid waterfalls:
export const Route = createFileRoute('/dashboard')({
loader: async ({ context: { queryClient } }) => {
await Promise.all([
queryClient.prefetchQuery(postsOptions()),
queryClient.prefetchQuery(commentsOptions()),
]);
},
});
function Dashboard() {
const posts = useSuspenseQuery(postsOptions());
const comments = useSuspenseQuery(commentsOptions());
}Solution 2: Use useSuspenseQueries for parallel fetching within components:
function Dashboard() {
const [posts, comments] = useSuspenseQueries({
queries: [
{ queryKey: ['posts'], queryFn: fetchPosts },
{ queryKey: ['comments'], queryFn: fetchComments },
],
});
}Key principle: Decouple data fetching from rendering. Initiate fetches in loaders, or use useSuspenseQueries for parallel fetching in components.
Partial Prerendering (PPR) with Next.js
PPR combines static and dynamic content in the same route. The server sends a static shell immediately, with dynamic holes streamed in asynchronously.
Enable PPR in next.config.ts:
export default {
experimental: {
ppr: 'incremental',
},
};Then opt-in per route:
export const experimental_ppr = true;Pattern for TanStack Query with PPR:
Pass an unwrapped promise from server components to client components without awaiting on the server:
async function ServerPage() {
const queryClient = new QueryClient();
const dataPromise = queryClient.prefetchQuery({
queryKey: ['data'],
queryFn: getData,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Suspense fallback={<Loading />}>
<ClientComponent dataPromise={dataPromise} />
</Suspense>
</HydrationBoundary>
);
}
function ClientComponent({ dataPromise }: { dataPromise: Promise<void> }) {
use(dataPromise);
const { data } = useSuspenseQuery({
queryKey: ['data'],
queryFn: getData,
});
return <div>{data}</div>;
}The static shell (layout, navigation) is served immediately from the edge. The dynamic Suspense boundary streams in after the query resolves, reducing overall load time while maintaining static prerendering benefits.
Error Boundaries with Suspense
Combine QueryErrorResetBoundary with react-error-boundary for retry-able error states:
import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';
function App() {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary }) => (
<div>
Something went wrong.
<button onClick={resetErrorBoundary}>Retry</button>
</div>
)}
>
<Suspense fallback={<Loading />}>
<Todos />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}
function Todos() {
const { data } = useSuspenseQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
return <TodoList todos={data} />;
}throwOnError defaults to throwing errors when there is no cached data to show. Queries with stale cached data render that data instead of throwing, even when a background refetch fails.
Hydration Mismatch Prevention
Common causes and fixes:
| Cause | Fix |
|---|---|
useQuery with conditional isLoading | Use useSuspenseQuery instead |
Void prefetch with fetchStatus render | Await prefetch or avoid rendering on isFetching |
Missing staleTime on server | Set staleTime > 0 to prevent immediate refetch |
| Shared QueryClient across requests | Create new QueryClient per server request |
Server-Side staleTime
Set staleTime on the server to prevent the client from immediately refetching data that was just fetched:
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60,
},
},
});
}Without staleTime, data is immediately stale on the client, triggering a refetch right after hydration -- wasting the prefetch.
Testing Patterns
Test Setup
Create a fresh QueryClient for each test to ensure complete isolation:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
}
function createWrapper() {
const queryClient = createTestQueryClient();
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}Retries cause test timeouts -- always set retry: false.
Network Mocking with MSW
Use Mock Service Worker as the single source of truth for API mocking:
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
const handlers = [
http.get('/api/todos', () => {
return HttpResponse.json([
{ id: '1', name: 'Learn TanStack Query' },
{ id: '2', name: 'Write tests' },
]);
}),
http.post('/api/todos', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: '3', ...body }, { status: 201 });
}),
];
const server = setupServer(...handlers);
// In test setup
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());Benefits: works in Node.js tests, browser, and Cypress. Single source of truth for mocks. Intercepts actual network requests (not fetch mocks).
Testing Queries
test('fetches todos successfully', async () => {
const { result } = renderHook(() => useTodosQuery(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toHaveLength(2);
expect(result.current.data?.[0].name).toBe('Learn TanStack Query');
});Testing Components
test('renders todos', async () => {
const queryClient = createTestQueryClient();
render(
<QueryClientProvider client={queryClient}>
<TodoList />
</QueryClientProvider>,
);
await waitFor(() => {
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
expect(screen.getByText('Learn TanStack Query')).toBeInTheDocument();
});Testing Mutations
test('creates todo successfully', async () => {
const { result } = renderHook(() => useCreateTodoMutation(), {
wrapper: createWrapper(),
});
act(() => {
result.current.mutate({ name: 'New todo' });
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.name).toBe('New todo');
});Testing Error States
Override handlers for specific tests:
test('handles error state', async () => {
server.use(
http.get('/api/todos', () => {
return HttpResponse.json({ message: 'Server error' }, { status: 500 });
}),
);
const { result } = renderHook(() => useTodosQuery(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toContain('500');
});Testing with Suspense
test('renders with suspense', async () => {
const queryClient = createTestQueryClient();
render(
<QueryClientProvider client={queryClient}>
<Suspense fallback={<div>Loading...</div>}>
<SuspenseTodoList />
</Suspense>
</QueryClientProvider>,
);
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Learn TanStack Query')).toBeInTheDocument();
});
});Pre-Populating Cache for Tests
Seed the cache to skip network requests:
test('renders with pre-populated cache', async () => {
const queryClient = createTestQueryClient();
queryClient.setQueryData(
['todos'],
[{ id: '1', name: 'Pre-populated todo' }],
);
render(
<QueryClientProvider client={queryClient}>
<TodoList />
</QueryClientProvider>,
);
expect(screen.getByText('Pre-populated todo')).toBeInTheDocument();
});Common Testing Mistakes
| Mistake | Why It's Wrong | Correct Approach |
|---|---|---|
| Shared QueryClient between tests | State leaks between tests | Create fresh client per test |
| Not disabling retries | Tests timeout waiting for retries | Set retry: false |
| Immediate assertions | Query hasn't completed | Use waitFor for async |
| Mocking fetch directly | Brittle, misses network layer | Use MSW |
| Testing without provider | Hook throws error | Always wrap in QueryClientProvider |
TypeScript Patterns
Let Inference Work
Type your queryFn return, not the useQuery generics:
async function fetchTodos(): Promise<Todo[]> {
const response = await fetch('/api/todos');
if (!response.ok) throw new Error('Failed to fetch');
return response.json();
}
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
}); // data is inferred as Todo[] | undefinedTypeScript lacks partial type argument inference. Specifying one generic forces you to specify all four (TQueryFnData, TError, TData, TQueryKey).
Type Narrowing Without Destructuring
const query = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
if (query.isSuccess) {
query.data; // Narrowed to Todo[]
}Destructuring breaks narrowing because TypeScript treats each destructured variable independently:
const { data, isSuccess } = useQuery({...});
if (isSuccess) {
data; // Still Todo[] | undefined -- narrowing lost
}skipToken for Type-Safe Disabling
Use skipToken instead of enabled: false for better type safety:
import { skipToken, useQuery } from '@tanstack/react-query';
function UserProfile({ userId }: { userId: string | undefined }) {
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: userId ? () => fetchUser(userId) : skipToken,
});
}No need for enabled option. TypeScript understands the query won't run when userId is undefined, and the queryFn closure properly narrows userId to string.
skipToken is not compatible with useSuspenseQuery or useSuspenseQueries — it will throw a runtime error because suspense queries must always fetch. Use component composition instead:
function UserDetailContainer({ userId }: { userId: string | undefined }) {
if (!userId) return <div>Select a user</div>;
return (
<Suspense fallback={<div>Loading...</div>}>
<UserDetail userId={userId} />
</Suspense>
);
}
function UserDetail({ userId }: { userId: string }) {
const { data } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
}Runtime Validation with Zod
TypeScript types don't exist at runtime. Validate API responses:
import { z } from 'zod';
const todoSchema = z.object({
id: z.string(),
name: z.string(),
completed: z.boolean(),
});
const todosSchema = z.array(todoSchema);
type Todo = z.infer<typeof todoSchema>;
async function fetchTodos(): Promise<Todo[]> {
const response = await fetch('/api/todos');
if (!response.ok) throw new Error('Failed to fetch');
const data = await response.json();
return todosSchema.parse(data);
}Parse errors become query failures, triggering React Query's error handling and retry logic.
Typing select Functions
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (todos) => todos.length,
}); // data is number | undefinedReusable query options with custom selectors:
function todoOptions<TData = Todo[]>(select?: (data: Todo[]) => TData) {
return queryOptions({
queryKey: ['todos'],
queryFn: fetchTodos,
select,
});
}
useQuery(todoOptions()); // data: Todo[] | undefined
useQuery(todoOptions((todos) => todos.length)); // data: number | undefinedTyping Mutations
Type both input variables and return type on the function:
type CreateTodoInput = { name: string; completed?: boolean };
const createTodo = async (input: CreateTodoInput): Promise<Todo> => {
const response = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(input),
});
if (!response.ok) throw new Error('Failed to create');
return response.json();
};
const mutation = useMutation({
mutationFn: createTodo,
}); // Variables typed as CreateTodoInput, data as TodoTyping useMutationState
useMutationState uses fuzzy key matching, so type inference is lost. Use the generic parameter:
const pendingTodos = useMutationState<CreateTodoInput>({
filters: { mutationKey: ['addTodo'], status: 'pending' },
select: (mutation) => mutation.state.variables,
});
// pendingTodos is CreateTodoInput[]Without the generic, mutation.state.variables is typed as unknown. This is a known limitation of fuzzy matching.
Error Type Handling
v5 defaults error type to Error. Handle errors defensively since JavaScript allows throwing any value:
if (query.error instanceof Error) {
return <div>Error: {query.error.message}</div>;
}For custom error types, use Zod for runtime validation:
const apiErrorSchema = z.object({
message: z.string(),
code: z.string(),
});
function isApiError(error: unknown): error is z.infer<typeof apiErrorSchema> {
return apiErrorSchema.safeParse(error).success;
}If throwing non-Error types from queryFn, register a global error type:
declare module '@tanstack/react-query' {
interface Register {
defaultError: AxiosError;
}
}ESLint Plugin
@tanstack/eslint-plugin-query catches common mistakes at lint time:
pnpm add -D @tanstack/eslint-plugin-queryimport pluginQuery from '@tanstack/eslint-plugin-query';
export default [...pluginQuery.configs['flat/recommended']];Key rules:
| Rule | What It Catches |
|---|---|
exhaustive-deps | Missing variables in queryKey that queryFn depends on |
stable-query-client | Creating QueryClient inside component body (no useState) |
no-rest-destructuring | Destructuring query result breaks type narrowing |
infinite-query-property-order | Wrong property order in infinite query options |
no-unstable-deps | Unstable references in queryKey (inline objects/arrays) |
End-to-End Type Safety
For full-stack TypeScript, consider:
- tRPC: Auto-infers frontend types from backend definitions
- Zodios: REST API client with Zod schema validation
- OpenAPI/Swagger: Generate types from API specs
WebSocket Integration
Strategy Selection
| Approach | Best For | Complexity |
|---|---|---|
| Event-based invalidation | Most apps, low-frequency updates | Low |
| Direct cache updates | High-frequency data (stock tickers) | Medium |
| Polling | Simple real-time without WebSocket | Low |
Event-Based Invalidation (Recommended)
Let React Query handle the refetch. WebSocket only signals staleness:
function useRealtimeSubscription() {
const queryClient = useQueryClient();
useEffect(() => {
const ws = new WebSocket('wss://api.example.com/events');
ws.onmessage = (event) => {
const { entity, id } = JSON.parse(event.data);
const queryKey = id ? [entity, id] : [entity];
queryClient.invalidateQueries({ queryKey });
};
return () => ws.close();
}, [queryClient]);
}Only active queries refetch. Works with existing query setup. Minimal code changes.
Direct Cache Updates
For high-frequency updates where refetching is too expensive, modify cache directly:
function useRealtimeUpdates() {
const queryClient = useQueryClient();
useEffect(() => {
const ws = new WebSocket('wss://api.example.com/stream');
ws.onmessage = (event) => {
const { entity, id, payload } = JSON.parse(event.data);
queryClient.setQueryData([entity, id], (old: unknown) => {
if (!old) return old;
return { ...old, ...payload };
});
queryClient.setQueriesData({ queryKey: [entity] }, (old: unknown) => {
if (!Array.isArray(old)) return old;
return old.map((item) =>
item.id === id ? { ...item, ...payload } : item,
);
});
};
return () => ws.close();
}, [queryClient]);
}setQueriesData updates all matching queries (fuzzy match). Use it to update both list and detail caches simultaneously.
Polling as Alternative
For simpler setups, use refetchInterval instead of WebSockets:
const { data } = useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
refetchInterval: 1000 * 30,
refetchIntervalInBackground: false,
});
const { data: jobStatus } = useQuery({
queryKey: ['job', jobId],
queryFn: () => getJobStatus(jobId),
refetchInterval: (query) => {
return query.state.data?.status === 'completed' ? false : 1000;
},
});Conditional polling stops once a condition is met, reducing unnecessary requests.
Configuration for WebSocket-Driven Apps
When WebSockets handle freshness, disable automatic refetching:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
},
});This prevents React Query from refetching on its own, relying entirely on WebSocket events to trigger cache updates or invalidation.
Reconnection Handling
Invalidate stale data when reconnecting after a disconnect:
function useRealtimeSubscription() {
const queryClient = useQueryClient();
useEffect(() => {
let ws: WebSocket;
function connect() {
ws = new WebSocket('wss://api.example.com/events');
ws.onopen = () => {
queryClient.invalidateQueries();
};
ws.onmessage = (event) => {
const { entity, id } = JSON.parse(event.data);
queryClient.invalidateQueries({
queryKey: id ? [entity, id] : [entity],
});
};
ws.onclose = () => {
setTimeout(connect, 3000);
};
}
connect();
return () => ws?.close();
}, [queryClient]);
}Invalidating all queries on reconnect ensures no data went stale during the disconnect window.