TanStack Query Patterns
Purpose
Modern data fetching with TanStack Query v5 (latest: 5.90.5, November 2025), emphasizing Suspense-based queries, cache-first strategies, and centralized API services.
Note: v5 (released October 2023) has breaking changes from v4:
-
isLoading → isPending for status
-
cacheTime → gcTime (garbage collection time)
-
React 18.0+ required
-
Callbacks removed from useQuery (onError, onSuccess, onSettled)
-
keepPreviousData replaced with placeholderData function
When to Use This Skill
-
Fetching data with TanStack Query
-
Using useSuspenseQuery or useQuery
-
Managing mutations
-
Cache invalidation and updates
-
API service patterns
Quick Start
Primary Pattern: useSuspenseQuery
For all new components, use useSuspenseQuery :
import { useSuspenseQuery } from '@tanstack/react-query'; import { postsApi } from '~/features/posts/api/postsApi';
function PostList() { const { data: posts } = useSuspenseQuery({ queryKey: ['posts'], queryFn: postsApi.getAll, });
return ( <div> {posts.map(post => ( <PostCard key={post.id} post={post} /> ))} </div> ); }
// Wrap with Suspense <Suspense fallback={<PostsSkeleton />}> <PostList /> </Suspense>
Benefits:
-
No isLoading checks needed
-
Integrates with Suspense boundaries
-
Cleaner component code
-
Consistent loading UX
useSuspenseQuery Patterns
Basic Usage
const { data } = useSuspenseQuery({ queryKey: ['user', userId], queryFn: () => userApi.get(userId), });
// data is never undefined - guaranteed by Suspense return <div>{data.name}</div>;
With Parameters
function UserPosts({ userId }: { userId: string }) { const { data: posts } = useSuspenseQuery({ queryKey: ['users', userId, 'posts'], queryFn: () => postsApi.getByUser(userId), });
return <div>{posts.length} posts</div>; }
Dependent Queries
function PostDetails({ postId }: { postId: string }) { // First query const { data: post } = useSuspenseQuery({ queryKey: ['posts', postId], queryFn: () => postsApi.get(postId), });
// Second query depends on first const { data: author } = useSuspenseQuery({ queryKey: ['users', post.authorId], queryFn: () => userApi.get(post.authorId), });
return <div>{author.name} wrote {post.title}</div>; }
useQuery (Legacy Pattern)
Use useQuery only when you need loading/error states in the component:
import { useQuery } from '@tanstack/react-query';
function Component() { const { data, isPending, error } = useQuery({ queryKey: ['posts'], queryFn: postsApi.getAll, });
if (isPending) return <Spinner />; if (error) return <Error error={error} />;
return <div>{data.map(...)}</div>; }
When to use useQuery vs useSuspenseQuery :
-
Use useSuspenseQuery by default (preferred)
-
Use useQuery only when you need component-level loading states
-
Most cases should use useSuspenseQuery
- Suspense boundaries
Mutations
Basic Mutation
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreatePostButton() { const queryClient = useQueryClient();
const mutation = useMutation({ mutationFn: postsApi.create, onSuccess: () => { // Invalidate and refetch queryClient.invalidateQueries({ queryKey: ['posts'] }); }, });
const handleCreate = () => { mutation.mutate({ title: 'New Post', content: 'Content here', }); };
return ( <button onClick={handleCreate} disabled={mutation.isPending}> {mutation.isPending ? 'Creating...' : 'Create Post'} </button> ); }
Optimistic Updates
const mutation = useMutation({ mutationFn: postsApi.update, onMutate: async (updatedPost) => { // Cancel outgoing refetches await queryClient.cancelQueries({ queryKey: ['posts', updatedPost.id] });
// Snapshot previous value
const previousPost = queryClient.getQueryData(['posts', updatedPost.id]);
// Optimistically update
queryClient.setQueryData(['posts', updatedPost.id], updatedPost);
// Return context with snapshot
return { previousPost };
}, onError: (err, updatedPost, context) => { // Rollback on error queryClient.setQueryData( ['posts', updatedPost.id], context.previousPost ); }, onSettled: (data, error, variables) => { // Refetch after mutation queryClient.invalidateQueries({ queryKey: ['posts', variables.id] }); }, });
Cache Management
Invalidation
import { useQueryClient } from '@tanstack/react-query';
const queryClient = useQueryClient();
// Invalidate all posts queries queryClient.invalidateQueries({ queryKey: ['posts'] });
// Invalidate specific post queryClient.invalidateQueries({ queryKey: ['posts', postId] });
// Invalidate all queries queryClient.invalidateQueries();
Manual Updates
// Update cache directly queryClient.setQueryData(['posts', postId], newPost);
// Update with function queryClient.setQueryData(['posts'], (oldPosts) => [ ...oldPosts, newPost, ]);
Prefetching
// Prefetch data await queryClient.prefetchQuery({ queryKey: ['posts', postId], queryFn: () => postsApi.get(postId), });
// In a component const prefetchPost = (postId: string) => { queryClient.prefetchQuery({ queryKey: ['posts', postId], queryFn: () => postsApi.get(postId), }); };
<Link
to={/posts/${post.id}}
onMouseEnter={() => prefetchPost(post.id)}
{post.title} </Link>
API Service Pattern
Centralized API Service
// features/posts/api/postsApi.ts import { apiClient } from '@/lib/apiClient'; import type { Post, CreatePostDto, UpdatePostDto } from '~/types/post';
export const postsApi = { getAll: async (): Promise<Post[]> => { const response = await apiClient.get('/posts'); return response.data; },
get: async (id: string): Promise<Post> => {
const response = await apiClient.get(/posts/${id});
return response.data;
},
create: async (data: CreatePostDto): Promise<Post> => { const response = await apiClient.post('/posts', data); return response.data; },
update: async (id: string, data: UpdatePostDto): Promise<Post> => {
const response = await apiClient.put(/posts/${id}, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await apiClient.delete(/posts/${id});
},
getByUser: async (userId: string): Promise<Post[]> => {
const response = await apiClient.get(/users/${userId}/posts);
return response.data;
},
};
Usage in Components
import { postsApi } from '~/features/posts/api/postsApi';
// In query const { data } = useSuspenseQuery({ queryKey: ['posts'], queryFn: postsApi.getAll, });
// In mutation const mutation = useMutation({ mutationFn: postsApi.create, });
Query Keys
Key Structure
// List queries ['posts'] // All posts ['posts', { status: 'published' }] // Filtered posts
// Detail queries ['posts', postId] // Single post ['posts', postId, 'comments'] // Post comments
// Nested resources ['users', userId, 'posts'] // User's posts ['users', userId, 'posts', postId] // Specific user post
Key Factories
// features/posts/api/postKeys.ts export const postKeys = { all: ['posts'] as const, lists: () => [...postKeys.all, 'list'] as const, list: (filters: string) => [...postKeys.lists(), { filters }] as const, details: () => [...postKeys.all, 'detail'] as const, detail: (id: string) => [...postKeys.details(), id] as const, comments: (id: string) => [...postKeys.detail(id), 'comments'] as const, };
// Usage const { data } = useSuspenseQuery({ queryKey: postKeys.detail(postId), queryFn: () => postsApi.get(postId), });
// Invalidate all post lists queryClient.invalidateQueries({ queryKey: postKeys.lists() });
Error Handling
With Error Boundaries
import { ErrorBoundary } from 'react-error-boundary';
<ErrorBoundary fallback={<ErrorFallback />}> <Suspense fallback={<Loading />}> <DataComponent /> </Suspense> </ErrorBoundary>
// In component function DataComponent() { const { data } = useSuspenseQuery({ queryKey: ['data'], queryFn: fetchData, // Errors automatically caught by ErrorBoundary });
return <div>{data}</div>; }
Retry and Cache Configuration
const { data } = useQuery({ queryKey: ['posts'], queryFn: postsApi.getAll, retry: 3, // Retry 3 times retryDelay: 1000, // Wait 1s between retries gcTime: 5 * 60 * 1000, // Garbage collection time: 5 minutes (v5: was 'cacheTime') });
Best Practices
- Use Suspense by Default
// ✅ Good: useSuspenseQuery + Suspense <Suspense fallback={<Skeleton />}> <DataComponent /> </Suspense>
function DataComponent() { const { data } = useSuspenseQuery({...}); return <div>{data}</div>; }
// ❌ Avoid: useQuery with manual loading function DataComponent() { const { data, isPending } = useQuery({...}); if (isPending) return <Spinner />; return <div>{data}</div>; }
- Consistent Query Keys
// ✅ Good: Use key factories const { data } = useSuspenseQuery({ queryKey: postKeys.detail(id), queryFn: () => postsApi.get(id), });
// ❌ Avoid: Inconsistent keys const { data } = useSuspenseQuery({ queryKey: ['post', id], // Different format queryFn: () => postsApi.get(id), });
- Centralized API Services
// ✅ Good: API service const { data } = useSuspenseQuery({ queryKey: ['posts'], queryFn: postsApi.getAll, });
// ❌ Avoid: Inline fetching const { data } = useSuspenseQuery({ queryKey: ['posts'], queryFn: async () => { const res = await fetch('/api/posts'); return res.json(); }, });
Additional Resources
For more patterns, see:
-
data-fetching.md - Advanced patterns
-
cache-strategies.md - Cache management
-
mutation-patterns.md - Complex mutations