TanStack Query: Complete Guide to Advanced Patterns and Best Practices
Master TanStack Query (React Query) with advanced patterns, real-world examples, and best practices. Learn query invalidation, optimistic updates, infinite queries, and production-ready patterns.
Table of Contents
- Introduction
- What is TanStack Query?
- Setting Up TanStack Query
- Core Concepts: Queries and Mutations
- Query Keys: The Foundation of Caching
- Advanced Query Patterns
- Mutations and Optimistic Updates
- Infinite Queries and Pagination
- Query Invalidation Strategies
- Error Handling and Retry Logic
- Performance Optimization
- TypeScript Integration
- Testing TanStack Query
- Real-World Patterns
- Common Pitfalls and Solutions
- Migration Guide
- Conclusion
Introduction
Managing server state in React applications is one of the most challenging aspects of modern frontend development. You need to handle loading states, error handling, caching, synchronization, refetching, and keeping your UI in sync with server data—all while maintaining good performance and user experience.
Traditional approaches using useState and useEffect require writing extensive boilerplate code for each data fetching operation. You end up manually managing loading states, error handling, request cancellation, caching logic, and refetching strategies. This leads to inconsistent behavior, performance issues, and code that becomes difficult to maintain as your application grows.
TanStack Query (formerly React Query) solves these problems by providing a powerful, declarative API for managing server state. It handles caching, background updates, request deduplication, and synchronization automatically, allowing you to focus on building features instead of managing data fetching infrastructure.
This comprehensive guide will take you from TanStack Query basics to advanced production patterns. You’ll learn how to structure queries effectively, implement optimistic updates, handle complex pagination scenarios, optimize performance, and write maintainable code that scales with your application.
By the end of this guide, you’ll have the knowledge and patterns needed to build robust, performant React applications with TanStack Query.
What is TanStack Query?
TanStack Query is a powerful data synchronization library for React applications that provides hooks and utilities for fetching, caching, synchronizing, and updating server state. It’s framework-agnostic at its core (TanStack Query Core) and has framework-specific adapters for React, Vue, Svelte, and Solid.
Key Features
- Intelligent Caching: Automatic caching with configurable stale times and garbage collection
- Background Refetching: Keeps data fresh without blocking the UI
- Request Deduplication: Multiple components requesting the same data share a single request
- Optimistic Updates: Update UI immediately before server confirmation
- Pagination Support: Built-in support for infinite queries and cursor-based pagination
- Error Handling: Configurable retry logic with exponential backoff
- DevTools: Powerful debugging tools for inspecting queries and cache
- TypeScript Support: Excellent TypeScript support with full type inference
- Suspense Integration: Works seamlessly with React Suspense
- Offline Support: Handles offline scenarios gracefully
Why Use TanStack Query?
Without TanStack Query:
function UserProfile({ userId }: { userId: string }) { const [user, setUser] = useState<User | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState<Error | null>(null);
useEffect(() => { let cancelled = false;
setLoading(true); setError(null);
fetch(`/api/users/${userId}`) .then(res => { if (!res.ok) throw new Error('Failed to fetch'); return res.json(); }) .then(data => { if (!cancelled) { setUser(data); setLoading(false); } }) .catch(err => { if (!cancelled) { setError(err); setLoading(false); } });
return () => { cancelled = true; }; }, [userId]);
if (loading) return <div>Loading...</div>; if (error) return <div>Error: {error.message}</div>; return <div>{user?.name}</div>;}With TanStack Query:
function UserProfile({ userId }: { userId: string }) { const { data: user, isLoading, error } = useQuery({ queryKey: ['user', userId], queryFn: () => fetch(`/api/users/${userId}`) .then(res => { if (!res.ok) throw new Error('Failed to fetch'); return res.json(); }), });
if (isLoading) return <div>Loading...</div>; if (error) return <div>Error: {error.message}</div>; return <div>{user?.name}</div>;}TanStack Query eliminates boilerplate, handles edge cases automatically, provides intelligent caching, and offers better performance through request deduplication and background refetching.
Setting Up TanStack Query
Installation
pnpm add @tanstack/react-queryFor development, also install the DevTools:
pnpm add @tanstack/react-query-devtoolsBasic Setup
Wrap your application with QueryClientProvider:
// main.tsx or App.tsximport { QueryClient, QueryClientProvider } from '@tanstack/react-query';import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
// Create a query client with default optionsconst queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5, // 5 minutes gcTime: 1000 * 60 * 10, // 10 minutes (formerly cacheTime) refetchOnWindowFocus: false, retry: 3, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), }, mutations: { retry: 1, }, },});
function App() { return ( <QueryClientProvider client={queryClient}> <YourApp /> {/* Add DevTools in development */} {process.env.NODE_ENV === 'development' && ( <ReactQueryDevtools initialIsOpen={false} /> )} </QueryClientProvider> );}QueryClient Configuration Options
const queryClient = new QueryClient({ defaultOptions: { queries: { // Time before data is considered stale (default: 0) staleTime: 1000 * 60 * 5, // 5 minutes
// Time before inactive queries are garbage collected (default: 5 minutes) gcTime: 1000 * 60 * 10, // 10 minutes
// Retry failed requests (default: 3) retry: 3, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
// Refetch options refetchOnWindowFocus: true, // Refetch when window regains focus refetchOnReconnect: true, // Refetch when network reconnects refetchOnMount: true, // Refetch when component mounts
// Network mode networkMode: "online", // 'online' | 'always' | 'offlineFirst' }, mutations: { retry: 1, // Retry failed mutations once networkMode: "online", }, },});Core Concepts: Queries and Mutations
useQuery Hook
useQuery is the primary hook for fetching data. It returns an object with query state and data.
import { useQuery } from '@tanstack/react-query';
type User = { id: string; name: string; email: string;};
function UserProfile({ userId }: { userId: string }) { const { data, // The fetched data (undefined while loading) isLoading, // true if query is loading and has no data isFetching, // true if query is currently fetching isError, // true if query is in error state error, // Error object if query failed refetch, // Function to manually refetch status, // 'loading' | 'error' | 'success' fetchStatus, // 'fetching' | 'paused' | 'idle' } = useQuery<User>({ queryKey: ['user', userId], queryFn: async () => { const response = await fetch(`/api/users/${userId}`); if (!response.ok) { throw new Error('Failed to fetch user'); } return response.json(); }, });
if (isLoading) return <div>Loading user...</div>; if (isError) return <div>Error: {error.message}</div>;
return ( <div> <h1>{data.name}</h1> <p>{data.email}</p> </div> );}Query Status Properties
Understanding the difference between isLoading and isFetching:
const { data, isLoading, isFetching, isRefetching } = useQuery({ queryKey: ["user", userId], queryFn: fetchUser,});
// isLoading: true only on initial load when there's no cached data// isFetching: true whenever a fetch is in progress (including background refetches)// isRefetching: true when refetching with existing datauseMutation Hook
Mutations are for creating, updating, or deleting data. Unlike queries, mutations are not automatically executed.
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreatePost() { const queryClient = useQueryClient();
const mutation = useMutation({ mutationFn: (newPost: { title: string; content: string }) => { return fetch('/api/posts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newPost), }).then(res => { if (!res.ok) throw new Error('Failed to create post'); return res.json(); }); }, onSuccess: () => { // Invalidate and refetch posts list queryClient.invalidateQueries({ queryKey: ['posts'] }); }, });
return ( <form onSubmit={(e) => { e.preventDefault(); const formData = new FormData(e.currentTarget); mutation.mutate({ title: formData.get('title') as string, content: formData.get('content') as string, }); }}> <input name="title" required /> <textarea name="content" required /> <button type="submit" disabled={mutation.isPending} > {mutation.isPending ? 'Creating...' : 'Create Post'} </button> {mutation.isError && ( <div>Error: {mutation.error.message}</div> )} </form> );}Query Keys: The Foundation of Caching
Query keys uniquely identify queries in the cache. They should be serializable, unique, and hierarchical.
Query Key Best Practices
// ✅ Good: Hierarchical and specific["users"][("users", userId)][("users", userId, "posts")][ // All users // Specific user // User's posts ("posts", { status: "published" })][("posts", { authorId: "123", status: "draft" })][ // Posts with filter // Multiple filters // ❌ Bad: Not specific enough "data"]["user"]; // Too generic // Missing identifierQuery Key Factory Pattern
Create typed query key factories for better maintainability:
export const queryKeys = { users: { all: ["users"] as const, detail: (id: string) => ["users", id] as const, posts: (id: string) => ["users", id, "posts"] as const, followers: (id: string) => ["users", id, "followers"] as const, }, posts: { all: ["posts"] as const, detail: (id: string) => ["posts", id] as const, byAuthor: (authorId: string) => ["posts", { authorId }] as const, byStatus: (status: string) => ["posts", { status }] as const, }, comments: { all: ["comments"] as const, byPost: (postId: string) => ["comments", { postId }] as const, },} as const;
// UsageuseQuery({ queryKey: queryKeys.users.detail(userId), queryFn: () => fetchUser(userId),});Query Functions
Query functions are async functions that return data or throw an error:
// Simple query functionconst fetchUser = async (userId: string): Promise<User> => { const response = await fetch(`/api/users/${userId}`); if (!response.ok) { throw new Error("Failed to fetch user"); } return response.json();};
// Query function with parameters from query keyconst fetchUserPosts = async ({ queryKey,}: { queryKey: readonly [string, string];}) => { const [, userId] = queryKey; const response = await fetch(`/api/users/${userId}/posts`); if (!response.ok) { throw new Error("Failed to fetch posts"); } return response.json();};
// Using in useQueryuseQuery({ queryKey: ["user", userId], queryFn: () => fetchUser(userId),});
useQuery({ queryKey: ["posts", userId], queryFn: fetchUserPosts, // React Query passes queryKey automatically});Advanced Query Patterns
Dependent Queries
Fetch queries sequentially based on previous query results:
function UserDashboard({ userId }: { userId: string }) { // First query: fetch user const { data: user } = useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId), });
// Second query: depends on user const { data: posts } = useQuery({ queryKey: ['posts', userId], queryFn: () => fetchUserPosts(userId), enabled: !!user, // Only run if user exists });
// Third query: depends on posts const { data: stats } = useQuery({ queryKey: ['stats', userId], queryFn: () => fetchUserStats(userId), enabled: !!posts && posts.length > 0, });
if (!user) return <div>Loading user...</div>; if (!posts) return <div>Loading posts...</div>;
return ( <div> <h1>{user.name}</h1> <PostList posts={posts} /> {stats && <StatsDisplay stats={stats} />} </div> );}Parallel Queries
Fetch multiple queries simultaneously:
function UserProfile({ userId }: { userId: string }) { // These queries run in parallel const userQuery = useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId), });
const postsQuery = useQuery({ queryKey: ['posts', userId], queryFn: () => fetchUserPosts(userId), });
const followersQuery = useQuery({ queryKey: ['followers', userId], queryFn: () => fetchUserFollowers(userId), });
const isLoading = userQuery.isLoading || postsQuery.isLoading || followersQuery.isLoading;
if (isLoading) return <div>Loading...</div>;
return ( <div> <h1>{userQuery.data?.name}</h1> <PostList posts={postsQuery.data} /> <FollowersList followers={followersQuery.data} /> </div> );}Dynamic Parallel Queries with useQueries
When you need to fetch a variable number of queries:
function UserList({ userIds }: { userIds: string[] }) { const userQueries = useQueries({ queries: userIds.map((id) => ({ queryKey: ['user', id], queryFn: () => fetchUser(id), })), });
const isLoading = userQueries.some(query => query.isLoading); const users = userQueries .map(query => query.data) .filter((user): user is User => user !== undefined);
if (isLoading) return <div>Loading users...</div>;
return ( <div> {users.map(user => ( <UserCard key={user.id} user={user} /> ))} </div> );}Query Options: Select, Placeholder, and Initial Data
// Select: Transform or select specific dataconst { data: userName } = useQuery({ queryKey: ["user", userId], queryFn: () => fetchUser(userId), select: (user) => user.name, // Only return the name});
// Placeholder data: Show placeholder while loadingconst { data } = useQuery({ queryKey: ["user", userId], queryFn: () => fetchUser(userId), placeholderData: { id: userId, name: "Loading...", email: "", },});
// Initial data: Skip loading state if data existsconst { data } = useQuery({ queryKey: ["user", userId], queryFn: () => fetchUser(userId), initialData: () => { // Get from cache or return undefined return queryClient.getQueryData(["user", userId]); }, initialDataUpdatedAt: () => { return queryClient.getQueryState(["user", userId])?.dataUpdatedAt; },});Mutations and Optimistic Updates
Basic Mutation
function EditPost({ postId }: { postId: string }) { const queryClient = useQueryClient();
const mutation = useMutation({ mutationFn: ({ title, content }: { title: string; content: string }) => { return fetch(`/api/posts/${postId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, content }), }).then(res => { if (!res.ok) throw new Error('Failed to update'); return res.json(); }); }, onSuccess: (updatedPost) => { // Update cache directly queryClient.setQueryData(['post', postId], updatedPost); // Invalidate related queries queryClient.invalidateQueries({ queryKey: ['posts'] }); }, });
return ( <form onSubmit={(e) => { e.preventDefault(); const formData = new FormData(e.currentTarget); mutation.mutate({ title: formData.get('title') as string, content: formData.get('content') as string, }); }}> <input name="title" /> <textarea name="content" /> <button type="submit" disabled={mutation.isPending}> {mutation.isPending ? 'Saving...' : 'Save'} </button> </form> );}Optimistic Updates
Optimistic updates make the UI feel instant by updating it before the server confirms:
function LikeButton({ postId }: { postId: string }) { const queryClient = useQueryClient();
const mutation = useMutation({ mutationFn: () => likePost(postId), // Called before mutation executes onMutate: async () => { // Cancel outgoing refetches await queryClient.cancelQueries({ queryKey: ['post', postId] });
// Snapshot previous value const previousPost = queryClient.getQueryData<Post>(['post', postId]);
// Optimistically update queryClient.setQueryData<Post>(['post', postId], (old) => { if (!old) return old; return { ...old, likes: old.likes + 1, liked: true, }; });
return { previousPost }; }, // Called if mutation fails onError: (err, variables, context) => { // Rollback optimistic update if (context?.previousPost) { queryClient.setQueryData(['post', postId], context.previousPost); } }, // Called after mutation completes onSettled: () => { // Refetch to ensure consistency queryClient.invalidateQueries({ queryKey: ['post', postId] }); }, });
return ( <button onClick={() => mutation.mutate()} disabled={mutation.isPending} > {mutation.isPending ? 'Liking...' : 'Like'} </button> );}Optimistic Update for Lists
function AddComment({ postId }: { postId: string }) { const queryClient = useQueryClient();
const mutation = useMutation({ mutationFn: (text: string) => createComment(postId, text), onMutate: async (text) => { await queryClient.cancelQueries({ queryKey: ['comments', postId] });
const previousComments = queryClient.getQueryData<Comment[]>(['comments', postId]);
// Create optimistic comment const optimisticComment: Comment = { id: `temp-${Date.now()}`, text, author: currentUser, createdAt: new Date().toISOString(), pending: true, // Mark as pending };
queryClient.setQueryData<Comment[]>(['comments', postId], (old = []) => [ ...old, optimisticComment, ]);
return { previousComments }; }, onError: (err, text, context) => { queryClient.setQueryData(['comments', postId], context?.previousComments); }, onSuccess: (newComment, text) => { // Replace optimistic comment with real one queryClient.setQueryData<Comment[]>(['comments', postId], (old = []) => old.map(comment => comment.pending && comment.text === text ? { ...newComment, pending: false } : comment ) ); }, });
return ( <form onSubmit={(e) => { e.preventDefault(); const formData = new FormData(e.currentTarget); mutation.mutate(formData.get('text') as string); e.currentTarget.reset(); }}> <input name="text" /> <button type="submit">Add Comment</button> </form> );}Infinite Queries and Pagination
Basic Infinite Query
import { useInfiniteQuery } from '@tanstack/react-query';
function PostList() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status, } = useInfiniteQuery({ queryKey: ['posts'], queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam), getNextPageParam: (lastPage, allPages) => { // Return next page number or undefined if no more pages return lastPage.hasNextPage ? allPages.length + 1 : undefined; }, initialPageParam: 1, });
if (status === 'loading') return <div>Loading...</div>; if (status === 'error') return <div>Error</div>;
return ( <div> {data.pages.map((page, i) => ( <div key={i}> {page.posts.map((post: Post) => ( <PostCard key={post.id} post={post} /> ))} </div> ))} <button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage} > {isFetchingNextPage ? 'Loading more...' : hasNextPage ? 'Load More' : 'Nothing more to load'} </button> </div> );}Cursor-Based Pagination
function PostList() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfiniteQuery({ queryKey: ['posts'], queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam }), getNextPageParam: (lastPage) => lastPage.nextCursor, initialPageParam: null, });
return ( <div> {data.pages.map((page, i) => ( <div key={i}> {page.posts.map((post: Post) => ( <PostCard key={post.id} post={post} /> ))} </div> ))} {hasNextPage && ( <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage} > {isFetchingNextPage ? 'Loading...' : 'Load More'} </button> )} </div> );}Infinite Scroll
import { useInView } from 'react-intersection-observer';
function InfinitePostList() { const { ref, inView } = useInView();
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfiniteQuery({ queryKey: ['posts'], queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam), getNextPageParam: (lastPage, allPages) => lastPage.hasNextPage ? allPages.length + 1 : undefined, initialPageParam: 1, });
useEffect(() => { if (inView && hasNextPage && !isFetchingNextPage) { fetchNextPage(); } }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
return ( <div> {data.pages.map((page, i) => ( <div key={i}> {page.posts.map((post: Post) => ( <PostCard key={post.id} post={post} /> ))} </div> ))} <div ref={ref}> {isFetchingNextPage && <div>Loading more...</div>} </div> </div> );}Query Invalidation Strategies
Invalidate Queries
After mutations, invalidate related queries to keep data in sync:
function CreatePost() { const queryClient = useQueryClient();
const mutation = useMutation({ mutationFn: createPost, onSuccess: () => { // Invalidate all posts queries queryClient.invalidateQueries({ queryKey: ["posts"] });
// Or invalidate specific query queryClient.invalidateQueries({ queryKey: ["posts", { status: "published" }], }); }, });
// ...}Update Query Data Directly
Update cache directly without refetching:
function UpdatePost() { const queryClient = useQueryClient();
const mutation = useMutation({ mutationFn: updatePost, onSuccess: (updatedPost) => { // Update specific post in cache queryClient.setQueryData(["post", updatedPost.id], updatedPost);
// Update post in list queryClient.setQueryData<Post[]>(["posts"], (old = []) => old.map((post) => (post.id === updatedPost.id ? updatedPost : post)), ); }, });
// ...}Remove Queries
Remove queries from cache:
function Logout() { const queryClient = useQueryClient();
const handleLogout = () => { // Remove all user-related queries queryClient.removeQueries({ queryKey: ['user'] }); // Or remove all queries queryClient.clear(); };
return <button onClick={handleLogout}>Logout</button>;}Error Handling and Retry Logic
Default Error Handling
function UserProfile({ userId }: { userId: string }) { const { data, error, isError } = useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId), });
if (isError) { return <div>Error: {error.message}</div>; }
return <div>{data?.name}</div>;}Custom Retry Logic
useQuery({ queryKey: ["user", userId], queryFn: () => fetchUser(userId), retry: 3, // Retry 3 times retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), // Exponential backoff: 1s, 2s, 4s, max 30s});Conditional Retry
useQuery({ queryKey: ["user", userId], queryFn: () => fetchUser(userId), retry: (failureCount, error) => { // Don't retry on 404 if (error instanceof Error && error.message.includes("404")) { return false; } // Retry up to 3 times for other errors return failureCount < 3; },});Global Error Handling
const queryClient = new QueryClient({ defaultOptions: { queries: { onError: (error) => { // Global error handler console.error("Query error:", error); // Send to error tracking service errorTrackingService.captureException(error); }, }, mutations: { onError: (error) => { // Global mutation error handler console.error("Mutation error:", error); }, }, },});Performance Optimization
Stale Time Configuration
Configure appropriate stale times for different data types:
// User data changes infrequentlyuseQuery({ queryKey: ["user", userId], queryFn: () => fetchUser(userId), staleTime: 1000 * 60 * 5, // 5 minutes});
// Notifications should be freshuseQuery({ queryKey: ["notifications"], queryFn: fetchNotifications, staleTime: 0, // Immediately stale refetchInterval: 1000 * 30, // Poll every 30 seconds});Prefetching
Prefetch queries to populate cache before they’re needed:
function PostCard({ post }: { post: Post }) { const queryClient = useQueryClient();
const handleMouseEnter = () => { // Prefetch user data on hover queryClient.prefetchQuery({ queryKey: ['user', post.authorId], queryFn: () => fetchUser(post.authorId), staleTime: 1000 * 60 * 5, }); };
return ( <div onMouseEnter={handleMouseEnter}> <h2>{post.title}</h2> </div> );}Select for Data Transformation
Use select to transform data and prevent unnecessary re-renders:
// Only re-render when userName changes, not when entire user object changesconst { data: userName } = useQuery({ queryKey: ["user", userId], queryFn: () => fetchUser(userId), select: (user) => user.name,});Suspense Integration
Use React Suspense for better loading states:
// Enable suspense modeconst queryClient = new QueryClient({ defaultOptions: { queries: { suspense: true, }, },});
// Componentfunction UserProfile({ userId }: { userId: string }) { const { data } = useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId), suspense: true, });
// data is guaranteed to be defined return <div>{data.name}</div>;}
// Appfunction App() { return ( <Suspense fallback={<div>Loading...</div>}> <UserProfile userId="123" /> </Suspense> );}TypeScript Integration
Typed Query Functions
type User = { id: string; name: string; email: string;};
function useUser(id: string) { return useQuery<User>({ queryKey: ["user", id], queryFn: async (): Promise<User> => { const response = await fetch(`/api/users/${id}`); if (!response.ok) throw new Error("Failed to fetch"); return response.json(); }, });}Typed Query Keys
export const queryKeys = { users: { all: ["users"] as const, detail: (id: string) => ["users", id] as const, posts: (id: string) => ["users", id, "posts"] as const, },} as const;
// Type-safe query key usagetype QueryKey = typeof queryKeys.users.detail extends (id: string) => infer R ? R : never;Typed Mutations
type CreatePostInput = { title: string; content: string;};
type Post = { id: string; title: string; content: string; createdAt: string;};
function useCreatePost() { return useMutation<Post, Error, CreatePostInput>({ mutationFn: async (input: CreatePostInput): Promise<Post> => { const response = await fetch("/api/posts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input), }); if (!response.ok) throw new Error("Failed to create post"); return response.json(); }, });}Testing TanStack Query
Testing Queries
import { renderHook, waitFor } from '@testing-library/react';import { QueryClient, QueryClientProvider } from '@tanstack/react-query';import { useQuery } from '@tanstack/react-query';
function createWrapper() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, }, });
return ({ children }: { children: React.ReactNode }) => ( <QueryClientProvider client={queryClient}> {children} </QueryClientProvider> );}
test('fetches user data', async () => { const { result } = renderHook( () => useQuery({ queryKey: ['user', '123'], queryFn: () => fetchUser('123'), }), { wrapper: createWrapper() } );
await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data).toEqual({ id: '123', name: 'John' });});Testing Mutations
test("creates a post", async () => { const { result } = renderHook( () => useMutation({ mutationFn: createPost, }), { wrapper: createWrapper() }, );
act(() => { result.current.mutate({ title: "Test", content: "Content" }); });
await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data).toEqual({ id: "1", title: "Test" });});Real-World Patterns
Custom Hooks Pattern
Create reusable query hooks:
export function useUser(userId: string) { return useQuery({ queryKey: queryKeys.users.detail(userId), queryFn: () => fetchUser(userId), staleTime: 1000 * 60 * 5, });}
// hooks/useUserPosts.tsexport function useUserPosts(userId: string) { return useQuery({ queryKey: queryKeys.users.posts(userId), queryFn: () => fetchUserPosts(userId), enabled: !!userId, });}
// Usagefunction UserProfile({ userId }: { userId: string }) { const { data: user } = useUser(userId); const { data: posts } = useUserPosts(userId); // ...}Query Factory Pattern
export const userQueries = { all: () => ["users"] as const, detail: (id: string) => ["users", id] as const, posts: (id: string) => ["users", id, "posts"] as const,};
export function useUser(id: string) { return useQuery({ queryKey: userQueries.detail(id), queryFn: () => fetchUser(id), });}
export function useUserPosts(id: string) { return useQuery({ queryKey: userQueries.posts(id), queryFn: () => fetchUserPosts(id), enabled: !!id, });}API Client Pattern
Create a centralized API client:
class ApiClient { async get<T>(url: string): Promise<T> { const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to fetch: ${response.statusText}`); } return response.json(); }
async post<T>(url: string, data: unknown): Promise<T> { const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); if (!response.ok) { throw new Error(`Failed to post: ${response.statusText}`); } return response.json(); }}
export const apiClient = new ApiClient();
// UsageuseQuery({ queryKey: ["user", userId], queryFn: () => apiClient.get<User>(`/api/users/${userId}`),});Common Pitfalls and Solutions
❌ Using TanStack Query for Client State
// ❌ Bad: Using TanStack Query for UI stateconst { data: isModalOpen } = useQuery({ queryKey: ["modal"], queryFn: () => false,});
// ✅ Good: Use useState for client stateconst [isModalOpen, setIsModalOpen] = useState(false);❌ Not Handling Loading States
// ❌ Bad: Accessing data without checking loading statefunction UserProfile({ id }: { id: string }) { const { data } = useQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id), });
return <div>{data.name}</div>; // Error if data is undefined!}
// ✅ Good: Handle loading statefunction UserProfile({ id }: { id: string }) { const { data, isLoading } = useQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id), });
if (isLoading) return <div>Loading...</div>; return <div>{data.name}</div>;}❌ Inconsistent Query Keys
// ❌ Bad: Inconsistent query keysuseQuery({ queryKey: ['user', id], ... });useQuery({ queryKey: ['users', id], ... }); // Different key!
// ✅ Good: Use query key factoryconst { data } = useQuery({ queryKey: queryKeys.users.detail(id), queryFn: () => fetchUser(id),});❌ Forgetting to Invalidate Queries
// ❌ Bad: Mutation doesn't update cacheconst mutation = useMutation({ mutationFn: createPost, // Missing onSuccess - cache won't update!});
// ✅ Good: Invalidate after mutationconst mutation = useMutation({ mutationFn: createPost, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["posts"] }); },});Migration Guide
From useState + useEffect
Before:
function UserProfile({ userId }: { userId: string }) { const [user, setUser] = useState<User | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState<Error | null>(null);
useEffect(() => { let cancelled = false;
setLoading(true); setError(null);
fetch(`/api/users/${userId}`) .then(res => { if (!res.ok) throw new Error('Failed to fetch'); return res.json(); }) .then(data => { if (!cancelled) { setUser(data); setLoading(false); } }) .catch(err => { if (!cancelled) { setError(err); setLoading(false); } });
return () => { cancelled = true; }; }, [userId]);
if (loading) return <div>Loading...</div>; if (error) return <div>Error: {error.message}</div>; return <div>{user?.name}</div>;}After:
function UserProfile({ userId }: { userId: string }) { const { data: user, isLoading, error } = useQuery({ queryKey: ['user', userId], queryFn: () => fetch(`/api/users/${userId}`) .then(res => { if (!res.ok) throw new Error('Failed to fetch'); return res.json(); }), });
if (isLoading) return <div>Loading...</div>; if (error) return <div>Error: {error.message}</div>; return <div>{user?.name}</div>;}Migration Checklist
- ✅ Install
@tanstack/react-query - ✅ Wrap app with
QueryClientProvider - ✅ Replace
useState+useEffectwithuseQuery - ✅ Replace manual loading/error states with query status
- ✅ Replace manual caching with TanStack Query cache
- ✅ Replace manual refetching with
invalidateQueries - ✅ Replace manual mutations with
useMutation - ✅ Add React Query DevTools for debugging
- ✅ Create query key factories for consistency
- ✅ Create custom hooks for reusability
Conclusion
TanStack Query revolutionizes how you handle server state in React applications. By eliminating boilerplate, providing intelligent caching, and handling complex scenarios like optimistic updates and pagination, it allows you to focus on building features rather than managing data fetching infrastructure.
Key takeaways:
- Use TanStack Query for server state, not client state
- Leverage automatic caching to improve performance and UX
- Implement optimistic updates for instant UI feedback
- Create reusable hooks and query key factories for maintainability
- Handle errors gracefully with proper retry logic
- Invalidate queries after mutations to keep data in sync
- Use DevTools to debug and optimize your queries
- Test your queries with proper setup and mocking
Whether you’re building a small application or a large-scale enterprise system, TanStack Query provides the tools you need to manage server state effectively. Start with the basics, gradually adopt advanced patterns, and you’ll find your data fetching code becoming simpler, more reliable, and more performant.
The investment in learning TanStack Query pays off quickly through reduced boilerplate, better user experiences, and more maintainable code. Your users will appreciate the faster, more responsive applications, and your team will appreciate the simpler, more predictable codebase.
For more advanced patterns and real-world examples, check out the official TanStack Query documentation and explore the TanStack Query examples repository.