TanStack Query (React Query) - Powerful Async State Management
Learn how to use TanStack Query for data fetching, caching, synchronization, and server state management in React and Next.js applications with automatic background updates and optimistic UI.
TanStack Query (React Query)
TanStack Query (formerly React Query) is a powerful library for fetching, caching, synchronizing, and updating server state in React applications. It eliminates the need for manual state management, loading states, and cache invalidation logic.
What is TanStack Query?
TanStack Query is not just a data fetching library - it's a comprehensive async state management solution that makes fetching, caching, synchronizing and updating server state a breeze.
Key Features
- Automatic caching: Intelligent caching with automatic garbage collection
- Background refetching: Keep data fresh without blocking UI
- Request deduplication: Prevent duplicate requests automatically
- Optimistic updates: Update UI before server confirms
- Pagination & infinite scroll: Built-in support for paginated data
- Parallel & dependent queries: Advanced query orchestration
- DevTools: Powerful debugging and inspection tools
- TypeScript support: Full type safety
- SSR compatible: Works with Next.js server components
Why TanStack Query?
Traditional state management libraries (Redux, MobX) are designed for client state, not server state. Server state has unique challenges:
- Data is stored remotely (you don't own it)
- Requires asynchronous APIs
- Can be changed by others without your knowledge
- Can become stale
- Needs caching, deduplication, and background updates
TanStack Query handles all of these concerns automatically.
Installation
Install TanStack Query in your Next.js project:
yarn add @tanstack/react-query
yarn add -D @tanstack/react-query-devtools
Setup
App Router Setup (Next.js 13+)
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
refetchOnWindowFocus: false,
},
},
}));
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
// app/layout.tsx
import { Providers } from './providers';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
Basic Usage
useQuery - Fetching Data
The useQuery hook fetches and caches data:
'use client';
import { useQuery } from '@tanstack/react-query';
interface User {
id: string;
name: string;
email: string;
}
function UsersPage() {
const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: async () => {
const response = await fetch('/api/users');
if (!response.ok) throw new Error('Failed to fetch users');
return response.json() as Promise<User[]>;
}
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h1>Users</h1>
{data?.map(user => (
<div key={user.id}>
{user.name} - {user.email}
</div>
))}
</div>
);
}
Query Keys
Query keys uniquely identify queries for caching:
// Simple key
useQuery({ queryKey: ['users'], queryFn: fetchUsers });
// Key with parameters
useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId)
});
// Complex key with multiple parameters
useQuery({
queryKey: ['users', { page, limit, sort }],
queryFn: () => fetchUsers({ page, limit, sort })
});
Query with TypeScript
interface Product {
id: string;
name: string;
price: number;
}
function ProductList() {
const { data, isLoading, error } = useQuery<Product[], Error>({
queryKey: ['products'],
queryFn: async () => {
const res = await fetch('/api/products');
return res.json();
}
});
// data is typed as Product[] | undefined
// error is typed as Error | null
}
Query States
TanStack Query provides detailed state information:
function DataComponent() {
const {
data,
error,
isLoading, // Initial loading state
isFetching, // Loading or refetching
isSuccess, // Query succeeded
isError, // Query failed
isPending, // Query is pending
isRefetching, // Background refetch
status, // 'pending' | 'error' | 'success'
fetchStatus, // 'fetching' | 'paused' | 'idle'
} = useQuery({
queryKey: ['data'],
queryFn: fetchData
});
if (isPending) return <div>Loading...</div>;
if (isError) return <div>Error: {error.message}</div>;
return (
<div>
{isFetching && <div>Updating...</div>}
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
);
}
useMutation - Modifying Data
The useMutation hook handles POST, PUT, PATCH, DELETE operations:
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface CreateUserData {
name: string;
email: string;
}
function CreateUserForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async (newUser: CreateUserData) => {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newUser)
});
return response.json();
},
onSuccess: () => {
// Invalidate and refetch users query
queryClient.invalidateQueries({ queryKey: ['users'] });
}
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
mutation.mutate({
name: formData.get('name') as string,
email: formData.get('email') as string
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<button disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create User'}
</button>
{mutation.isError && <div>Error: {mutation.error.message}</div>}
{mutation.isSuccess && <div>User created successfully!</div>}
</form>
);
}
Cache Management
Invalidating Queries
Tell TanStack Query that data is stale and needs refetching:
const queryClient = useQueryClient();
// Invalidate specific query
queryClient.invalidateQueries({ queryKey: ['users'] });
// Invalidate multiple queries
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['posts'] });
// Invalidate all queries starting with 'user'
queryClient.invalidateQueries({ queryKey: ['user'] });
Updating Cache Manually
const queryClient = useQueryClient();
// Set data in cache
queryClient.setQueryData(['user', userId], newUserData);
// Update data in cache
queryClient.setQueryData(['user', userId], (oldData) => ({
...oldData,
name: 'Updated Name'
}));
// Get data from cache
const cachedUser = queryClient.getQueryData(['user', userId]);
Optimistic Updates
Update UI immediately, then sync with server:
const mutation = useMutation({
mutationFn: updateUser,
onMutate: async (newUser) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['user', newUser.id] });
// Snapshot previous value
const previousUser = queryClient.getQueryData(['user', newUser.id]);
// Optimistically update
queryClient.setQueryData(['user', newUser.id], newUser);
// Return context for rollback
return { previousUser };
},
onError: (err, newUser, context) => {
// Rollback on error
if (context?.previousUser) {
queryClient.setQueryData(
['user', newUser.id],
context.previousUser
);
}
},
onSettled: (newUser) => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: ['user', newUser?.id] });
}
});
Advanced Patterns
Dependent Queries
Fetch data that depends on previous queries:
function UserPosts({ userId }: { userId: string }) {
// First query - get user
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId)
});
// Second query - depends on first query
const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetchUserPosts(user!.id),
enabled: !!user // Only run when user is available
});
return <div>{/* Render user and posts */}</div>;
}
Parallel Queries
Fetch multiple resources simultaneously:
function Dashboard() {
const usersQuery = useQuery({
queryKey: ['users'],
queryFn: fetchUsers
});
const postsQuery = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts
});
const commentsQuery = useQuery({
queryKey: ['comments'],
queryFn: fetchComments
});
const isLoading = usersQuery.isLoading ||
postsQuery.isLoading ||
commentsQuery.isLoading;
if (isLoading) return <div>Loading...</div>;
return (
<div>
<UsersList data={usersQuery.data} />
<PostsList data={postsQuery.data} />
<CommentsList data={commentsQuery.data} />
</div>
);
}
Using useQueries for Dynamic Parallel Queries
function UserProfiles({ userIds }: { userIds: string[] }) {
const userQueries = useQueries({
queries: userIds.map(id => ({
queryKey: ['user', id],
queryFn: () => fetchUser(id)
}))
});
const isLoading = userQueries.some(query => query.isLoading);
const allData = userQueries.map(query => query.data);
return <div>{/* Render all users */}</div>;
}
Pagination
Traditional Pagination
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
function ProductsList() {
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({
queryKey: ['products', page],
queryFn: () => fetchProducts(page),
placeholderData: (previousData) => previousData, // Keep old data while fetching
});
return (
<div>
{isLoading ? (
<div>Loading...</div>
) : (
<>
{data?.products.map(product => (
<div key={product.id}>{product.name}</div>
))}
<button
onClick={() => setPage(p => p - 1)}
disabled={page === 1}
>
Previous
</button>
<span>Page {page}</span>
<button
onClick={() => setPage(p => p + 1)}
disabled={!data?.hasMore}
>
Next
</button>
</>
)}
</div>
);
}
Infinite Queries (Infinite Scroll)
'use client';
import { useInfiniteQuery } from '@tanstack/react-query';
interface PostsResponse {
posts: Post[];
nextCursor?: number;
}
function InfinitePostsList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: async ({ pageParam = 0 }) => {
const response = await fetch(`/api/posts?cursor=${pageParam}`);
return response.json() as Promise<PostsResponse>;
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: 0,
});
if (isLoading) return <div>Loading...</div>;
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.posts.map(post => (
<div key={post.id}>
<h3>{post.title}</h3>
<p>{post.content}</p>
</div>
))}
</div>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? 'Loading more...'
: hasNextPage
? 'Load More'
: 'Nothing more to load'}
</button>
</div>
);
}
Prefetching
Load data before it's needed:
'use client';
import { useQuery, useQueryClient } from '@tanstack/react-query';
function ProductCard({ productId }: { productId: string }) {
const queryClient = useQueryClient();
// Prefetch on hover
const handleMouseEnter = () => {
queryClient.prefetchQuery({
queryKey: ['product', productId],
queryFn: () => fetchProduct(productId),
staleTime: 60 * 1000, // Cache for 1 minute
});
};
return (
<div onMouseEnter={handleMouseEnter}>
<Link href={`/products/${productId}`}>
View Product
</Link>
</div>
);
}
Server-Side Prefetching (Next.js)
// app/products/page.tsx
import {
dehydrate,
HydrationBoundary,
QueryClient
} from '@tanstack/react-query';
import { ProductsList } from './products-list';
export default async function ProductsPage() {
const queryClient = new QueryClient();
// Prefetch on server
await queryClient.prefetchQuery({
queryKey: ['products'],
queryFn: fetchProducts,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProductsList />
</HydrationBoundary>
);
}
Background Refetching
TanStack Query automatically refetches data in the background:
const { data } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
staleTime: 60 * 1000, // Consider fresh for 1 minute
refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes
refetchOnWindowFocus: true, // Refetch when window regains focus
refetchOnReconnect: true, // Refetch when internet reconnects
refetchOnMount: true, // Refetch when component mounts
});
Error Handling
Query-Level Error Handling
function UserProfile({ userId }: { userId: string }) {
const { data, error, isError } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
retry: 3, // Retry failed requests 3 times
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
if (isError) {
return (
<div>
<h2>Error loading user</h2>
<p>{error.message}</p>
<button onClick={() => refetch()}>Try Again</button>
</div>
);
}
return <div>{/* Render user */}</div>;
}
Global Error Handling
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
onError: (error) => {
console.error('Query error:', error);
toast.error('Failed to load data');
},
},
mutations: {
onError: (error) => {
console.error('Mutation error:', error);
toast.error('Failed to save changes');
},
},
},
});
Real-World Examples
Complete CRUD Example
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
interface Todo {
id: string;
title: string;
completed: boolean;
}
function TodoApp() {
const queryClient = useQueryClient();
// Fetch todos
const { data: todos, isLoading } = useQuery({
queryKey: ['todos'],
queryFn: async () => {
const res = await fetch('/api/todos');
return res.json() as Promise<Todo[]>;
},
});
// Create todo
const createMutation = useMutation({
mutationFn: async (title: string) => {
const res = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
// Update todo
const updateMutation = useMutation({
mutationFn: async (todo: Todo) => {
const res = await fetch(`/api/todos/${todo.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(todo),
});
return res.json();
},
onMutate: async (updatedTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.map(todo =>
todo.id === updatedTodo.id ? updatedTodo : todo
)
);
return { previousTodos };
},
onError: (_err, _todo, context) => {
queryClient.setQueryData(['todos'], context?.previousTodos);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
// Delete todo
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
await fetch(`/api/todos/${id}`, { method: 'DELETE' });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
if (isLoading) return <div>Loading...</div>;
return (
<div>
<h1>Todos</h1>
<form onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
createMutation.mutate(formData.get('title') as string);
e.currentTarget.reset();
}}>
<input name="title" placeholder="New todo" required />
<button disabled={createMutation.isPending}>Add</button>
</form>
<ul>
{todos?.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() =>
updateMutation.mutate({
...todo,
completed: !todo.completed
})
}
/>
<span style={{
textDecoration: todo.completed ? 'line-through' : 'none'
}}>
{todo.title}
</span>
<button onClick={() => deleteMutation.mutate(todo.id)}>
Delete
</button>
</li>
))}
</ul>
</div>
);
}
DevTools
TanStack Query DevTools provide powerful debugging:
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools
initialIsOpen={false}
position="bottom-right"
/>
</QueryClientProvider>
);
}
DevTools features:
- View all queries and their states
- Inspect query data
- Manually trigger refetches
- View query timeline
- Debug cache behavior
Best Practices
1. Use Query Keys Consistently
// Good - consistent key structure
const todoKeys = {
all: ['todos'] as const,
lists: () => [...todoKeys.all, 'list'] as const,
list: (filters: string) => [...todoKeys.lists(), { filters }] as const,
details: () => [...todoKeys.all, 'detail'] as const,
detail: (id: string) => [...todoKeys.details(), id] as const,
};
// Usage
useQuery({ queryKey: todoKeys.detail(id), queryFn: () => fetchTodo(id) });
2. Configure Sensible Defaults
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
refetchOnWindowFocus: false,
retry: 1,
},
},
});
3. Separate Concerns
// hooks/useTodos.ts
export function useTodos() {
return useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
}
export function useCreateTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createTodo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}
// Component
function TodoList() {
const { data } = useTodos();
const createTodo = useCreateTodo();
return <div>{/* ... */}</div>;
}
4. Handle Loading and Error States
function DataComponent() {
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: ['data'],
queryFn: fetchData,
});
if (isLoading) return <LoadingSkeleton />;
if (isError) {
return (
<ErrorState
message={error.message}
onRetry={refetch}
/>
);
}
return <DataDisplay data={data} />;
}
Summary
TanStack Query is a game-changer for data fetching in React applications. It provides:
- Automatic caching with intelligent invalidation
- Background updates to keep data fresh
- Optimistic updates for instant UI feedback
- Request deduplication to prevent redundant calls
- Pagination & infinite scroll out of the box
- DevTools for debugging
- TypeScript support for type safety
By handling all the complexities of server state management, TanStack Query lets you focus on building features instead of managing boilerplate code. It's especially powerful when combined with Axios for HTTP requests or used with Next.js API routes.
In the next lesson, we'll explore Refine, a complete framework that builds on these concepts to provide full CRUD application scaffolding.