Skip to main content

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

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

  1. Intelligent Caching: Automatic caching with configurable stale times and garbage collection
  2. Background Refetching: Keeps data fresh without blocking the UI
  3. Request Deduplication: Multiple components requesting the same data share a single request
  4. Optimistic Updates: Update UI immediately before server confirmation
  5. Pagination Support: Built-in support for infinite queries and cursor-based pagination
  6. Error Handling: Configurable retry logic with exponential backoff
  7. DevTools: Powerful debugging tools for inspecting queries and cache
  8. TypeScript Support: Excellent TypeScript support with full type inference
  9. Suspense Integration: Works seamlessly with React Suspense
  10. 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

Terminal window
pnpm add @tanstack/react-query

For development, also install the DevTools:

Terminal window
pnpm add @tanstack/react-query-devtools

Basic Setup

Wrap your application with QueryClientProvider:

// main.tsx or App.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
// Create a query client with default options
const 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 data

useMutation 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 identifier

Query Key Factory Pattern

Create typed query key factories for better maintainability:

utils/queryKeys.ts
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;
// Usage
useQuery({
queryKey: queryKeys.users.detail(userId),
queryFn: () => fetchUser(userId),
});

Query Functions

Query functions are async functions that return data or throw an error:

// Simple query function
const 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 key
const 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 useQuery
useQuery({
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 data
const { data: userName } = useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
select: (user) => user.name, // Only return the name
});
// Placeholder data: Show placeholder while loading
const { data } = useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
placeholderData: {
id: userId,
name: "Loading...",
email: "",
},
});
// Initial data: Skip loading state if data exists
const { 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 infrequently
useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
staleTime: 1000 * 60 * 5, // 5 minutes
});
// Notifications should be fresh
useQuery({
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 changes
const { data: userName } = useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
select: (user) => user.name,
});

Suspense Integration

Use React Suspense for better loading states:

// Enable suspense mode
const queryClient = new QueryClient({
defaultOptions: {
queries: {
suspense: true,
},
},
});
// Component
function 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>;
}
// App
function 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

utils/queryKeys.ts
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 usage
type 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:

hooks/useUser.ts
export function useUser(userId: string) {
return useQuery({
queryKey: queryKeys.users.detail(userId),
queryFn: () => fetchUser(userId),
staleTime: 1000 * 60 * 5,
});
}
// hooks/useUserPosts.ts
export function useUserPosts(userId: string) {
return useQuery({
queryKey: queryKeys.users.posts(userId),
queryFn: () => fetchUserPosts(userId),
enabled: !!userId,
});
}
// Usage
function UserProfile({ userId }: { userId: string }) {
const { data: user } = useUser(userId);
const { data: posts } = useUserPosts(userId);
// ...
}

Query Factory Pattern

queries/userQueries.ts
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:

api/client.ts
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();
// Usage
useQuery({
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 state
const { data: isModalOpen } = useQuery({
queryKey: ["modal"],
queryFn: () => false,
});
// ✅ Good: Use useState for client state
const [isModalOpen, setIsModalOpen] = useState(false);

❌ Not Handling Loading States

// ❌ Bad: Accessing data without checking loading state
function 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 state
function 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 keys
useQuery({ queryKey: ['user', id], ... });
useQuery({ queryKey: ['users', id], ... }); // Different key!
// ✅ Good: Use query key factory
const { data } = useQuery({
queryKey: queryKeys.users.detail(id),
queryFn: () => fetchUser(id),
});

❌ Forgetting to Invalidate Queries

// ❌ Bad: Mutation doesn't update cache
const mutation = useMutation({
mutationFn: createPost,
// Missing onSuccess - cache won't update!
});
// ✅ Good: Invalidate after mutation
const 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

  1. ✅ Install @tanstack/react-query
  2. ✅ Wrap app with QueryClientProvider
  3. ✅ Replace useState + useEffect with useQuery
  4. ✅ Replace manual loading/error states with query status
  5. ✅ Replace manual caching with TanStack Query cache
  6. ✅ Replace manual refetching with invalidateQueries
  7. ✅ Replace manual mutations with useMutation
  8. ✅ Add React Query DevTools for debugging
  9. ✅ Create query key factories for consistency
  10. ✅ 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.