On this page
Data Fetching with TanStack Query: Caching, Mutations, and Pagination
Why Not Just useEffect for Data Fetching?
Manual data fetching with useEffect works but leaves you responsible for:
- Managing loading, error, and data states manually
- Cancelling stale requests on unmount
- Caching and deduplicating concurrent requests
- Background refetching when the user returns to the tab
- Synchronizing data across multiple components
- Retry logic on failure
- Pagination and infinite scroll
TanStack Query (formerly React Query) handles all of these concerns with a few lines of code, making it the de facto standard for server state management in React applications.
Installation
npm install @tanstack/react-query
# Optional: Devtools
npm install @tanstack/react-query-devtoolsSetup
Create a QueryClient at module level and provide it at the app root:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes — data is fresh for 5 min
retry: 2, // retry failed requests twice
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}useQuery — Fetching Data
useQuery fetches, caches, and manages server state:
import { useQuery } from '@tanstack/react-query';
interface Product {
id: number;
title: string;
price: number;
image: string;
}
async function fetchProduct(id: number): Promise<Product> {
const res = await fetch(`https://fakestoreapi.com/products/${id}`);
if (!res.ok) throw new Error(`Product ${id} not found`);
return res.json() as Promise<Product>;
}
function ProductDetail({ productId }: { productId: number }) {
const {
data: product,
isLoading,
isError,
error,
isFetching, // true during background refetch
isStale, // true when data is older than staleTime
} = useQuery({
queryKey: ['product', productId],
queryFn: () => fetchProduct(productId),
enabled: productId > 0, // only run when productId is valid
});
if (isLoading) return <p>Loading product...</p>;
if (isError) return <p role="alert">Error: {(error as Error).message}</p>;
if (!product) return null;
return (
<article>
{isFetching && <span className="refresh-indicator">Refreshing...</span>}
<img src={product.image} alt={product.title} width={200} height={200} />
<h1>{product.title}</h1>
<p>${product.price}</p>
</article>
);
}useMutation — Creating, Updating, Deleting
Mutations handle data modifications. They include lifecycle callbacks for optimistic updates, error rollback, and cache invalidation:
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface NewProduct {
title: string;
price: number;
category: string;
}
async function createProduct(product: NewProduct): Promise<Product> {
const res = await fetch('https://fakestoreapi.com/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(product),
});
if (!res.ok) throw new Error('Failed to create product');
return res.json() as Promise<Product>;
}
function AddProductForm() {
const queryClient = useQueryClient();
const [title, setTitle] = useState('');
const [price, setPrice] = useState('');
const { mutate, isPending, isError, error } = useMutation({
mutationFn: createProduct,
onSuccess: () => {
// Invalidate and refetch the products list
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
mutate({ title, price: Number(price), category: 'electronics' });
}
return (
<form onSubmit={handleSubmit}>
{isError && <p role="alert">Error: {(error as Error).message}</p>}
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Product title"
required
/>
<input
type="number"
value={price}
onChange={(e) => setPrice(e.target.value)}
placeholder="Price"
required
/>
<button type="submit" disabled={isPending}>
{isPending ? 'Adding...' : 'Add product'}
</button>
</form>
);
}Query Invalidation
After a mutation, you typically want to refetch affected queries. Use invalidateQueries to mark queries as stale and trigger a background refetch:
const queryClient = useQueryClient();
// Invalidate all queries with key starting with 'products'
queryClient.invalidateQueries({ queryKey: ['products'] });
// Invalidate a specific product
queryClient.invalidateQueries({ queryKey: ['product', 42] });
// Refetch immediately (instead of lazily on next access)
queryClient.refetchQueries({ queryKey: ['products'] });Prefetching Data
Prefetch data before the user navigates to it — for example, on hover over a link:
function ProductLink({ productId, title }: { productId: number; title: string }) {
const queryClient = useQueryClient();
function prefetch() {
queryClient.prefetchQuery({
queryKey: ['product', productId],
queryFn: () => fetchProduct(productId),
staleTime: 1000 * 60 * 10, // keep prefetched data fresh for 10 minutes
});
}
return (
<a
href={`/products/${productId}`}
onMouseEnter={prefetch}
onFocus={prefetch}
>
{title}
</a>
);
}Dependent Queries
Some queries depend on the result of another. Use the enabled option:
function UserPosts({ userId }: { userId: string | null }) {
// First query: get user details
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId!),
enabled: !!userId,
});
// Second query: depends on user.email from the first query
const { data: posts } = useQuery({
queryKey: ['posts', user?.email],
queryFn: () => fetchPostsByEmail(user!.email),
enabled: !!user?.email, // only runs after user is fetched
});
return (
<div>
<h2>{user?.name}</h2>
<ul>{posts?.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
</div>
);
}Infinite Queries
For infinite scroll, use useInfiniteQuery:
import { useInfiniteQuery } from '@tanstack/react-query';
function InfinitePostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts', 'infinite'],
queryFn: ({ pageParam }) => fetchPosts(pageParam as number),
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) =>
lastPage.length < 10 ? undefined : allPages.length + 1,
});
const allPosts = data?.pages.flat() ?? [];
return (
<div>
<ul>
{allPosts.map((post) => <li key={post.id}>{post.title}</li>)}
</ul>
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading more...' : hasNextPage ? 'Load more' : 'All loaded'}
</button>
</div>
);
}In the next lesson, you will combine TanStack Query with Suspense and Error Boundaries for a declarative loading and error handling experience.
import {
useQuery,
useMutation,
useQueryClient,
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query';
interface Post {
id: number;
title: string;
userId: number;
}
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 10, // 10 minutes
retry: 2,
},
},
});
async function fetchPosts(): Promise<Post[]> {
const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=10');
if (!res.ok) throw new Error('Failed to fetch posts');
return res.json() as Promise<Post[]>;
}
async function createPost(title: string): Promise<Post> {
const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, userId: 1 }),
});
if (!res.ok) throw new Error('Failed to create post');
return res.json() as Promise<Post>;
}
function PostList() {
const qc = useQueryClient();
const { data: posts, isLoading, isError, error } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
const mutation = useMutation({
mutationFn: createPost,
onSuccess: (newPost) => {
// Optimistic cache update
qc.setQueryData<Post[]>(['posts'], (old) =>
old ? [newPost, ...old] : [newPost]
);
},
onError: () => {
qc.invalidateQueries({ queryKey: ['posts'] });
},
});
if (isLoading) return <p>Loading posts...</p>;
if (isError) return <p role="alert">Error: {(error as Error).message}</p>;
return (
<div>
<button
type="button"
onClick={() => mutation.mutate('New Post Title')}
disabled={mutation.isPending}
>
{mutation.isPending ? 'Creating...' : 'Create Post'}
</button>
<ul>
{posts?.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}
export function App() {
return (
<QueryClientProvider client={queryClient}>
<PostList />
</QueryClientProvider>
);
}
Sign in to track your progress