Data Fetching with TanStack Query — React Query v5
In this tutorial, you'll learn about Data Fetching with TanStack Query. We cover key concepts, practical examples, and best practices.
What You'll Learn
Manage server state in React with TanStack Query (React Query v5) — caching, background refetching, pagination, infinite queries, optimistic updates, and mutation with TypeScript.
Why It Matters
Most React apps spend 60% of code on data fetching, caching, and state synchronization. TanStack Query eliminates boilerplate by providing automatic caching, background refetching, deduplication, and stale-while-revalidate — patterns that are tedious to implement manually with useEffect.
Real-World Use
A real-time dashboard that fetches stock prices every 30 seconds, automatically caches responses, refetches on window focus, paginates through trade history, and optimistically updates the UI when the user places a trade order.
Learning Path
flowchart LR
A[React Basics] --> B[API Integration]
B --> C[TanStack Query]
C --> D[State Management]
D --> E[Performance Optimization]
E --> F[Final Project]
C -->|You are here| C
Setting Up QueryClient and Provider
TanStack Query manages server state outside of React's component tree. The QueryClientProvider gives access to all query hooks.
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
gcTime: 1000 * 60 * 30, // 30 minutes (formerly cacheTime)
retry: 2,
refetchOnWindowFocus: true,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<Dashboard />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Expected output: The app renders with TanStack Query enabled. The DevTools icon appears in the bottom corner — clicking it shows cached queries, their status, and timestamps. Data stays fresh for 5 minutes and garbage-collected after 30 minutes of inactivity.
Basic Queries with Loading and Error States
The useQuery hook fetches, caches, and synchronizes data.
import { useQuery } from "@tanstack/react-query";
interface Repository {
id: number;
name: string;
description: string;
stargazers_count: number;
}
async function fetchRepos(): Promise<Repository[]> {
const res = await fetch("https://api.github.com/orgs/reactjs/repos");
if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
return res.json();
}
function RepoList() {
const { data, isLoading, error, refetch } = useQuery<Repository[]>({
queryKey: ["repos", "reactjs"],
queryFn: fetchRepos,
staleTime: 1000 * 60 * 2, // 2 minutes
});
if (isLoading) return <div aria-label="Loading">Loading repositories...</div>;
if (error) return (
<div role="alert">
Error: {(error as Error).message}
<button onClick={() => refetch()}>Retry</button>
</div>
);
return (
<ul>
{data?.map((repo) => (
<li key={repo.id}>
<strong>{repo.name}</strong> — {repo.description}
<span> Stars: {repo.stargazers_count}</span>
</li>
))}
</ul>
);
}
Expected output: Initially shows "Loading repositories..." then renders the list of React organization repos with star counts. If the network fails, shows the error with a Retry button. The refetch function allows manual retry. Navigating away and back shows cached data instantly with a background refetch.
Mutations and Optimistic Updates
Mutations handle create, update, and delete operations. Optimistic updates update the UI before the server confirms.
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
interface Todo {
id: number;
title: string;
completed: boolean;
}
async function fetchTodos(): Promise<Todo[]> {
const res = await fetch("https://jsonplaceholder.typicode.com/todos?_limit=10");
return res.json();
}
async function addTodo(newTodo: Omit<Todo, "id">): Promise<Todo> {
const res = await fetch("https://jsonplaceholder.typicode.com/todos", {
method: "POST",
body: JSON.stringify(newTodo),
headers: { "Content-Type": "application/json" },
});
return res.json();
}
function TodoList() {
const queryClient = useQueryClient();
const { data: todos } = useQuery<Todo[]>({
queryKey: ["todos"],
queryFn: fetchTodos,
});
const mutation = useMutation({
mutationFn: addTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ["todos"] });
const previousTodos = queryClient.getQueryData<Todo[]>(["todos"]);
queryClient.setQueryData<Todo[]>(["todos"], (old) => [
...(old || []),
{ ...newTodo, id: Date.now() },
]);
return { previousTodos };
},
onError: (_err, _newTodo, context) => {
// Rollback on error
if (context?.previousTodos) {
queryClient.setQueryData(["todos"], context.previousTodos);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const form = e.target as HTMLFormElement;
const title = new FormData(form).get("title") as string;
if (!title.trim()) return;
mutation.mutate({ title, completed: false });
form.reset();
}
return (
<div>
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Add a todo..." />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Adding..." : "Add"}
</button>
</form>
<ul>
{todos?.map((todo) => (
<li key={todo.id}>
<span style={{ textDecoration: todo.completed ? "line-through" : "none" }}>
{todo.title}
</span>
</li>
))}
</ul>
{mutation.isError && (
<p role="alert">Failed to add todo: {(mutation.error as Error).message}</p>
)}
</div>
);
}
Expected output: Adding a todo immediately shows it in the list (optimistic update). If the server rejects the request, the todo disappears (rollback). The "Add" button shows "Adding..." during submission. The invalidateQueries call refetches the full list after the mutation settles for consistency.
Pagination and Infinite Queries
TanStack Query supports both offset-based pagination and cursor-based infinite queries.
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
}
interface Product {
id: number;
title: string;
price: number;
}
async function fetchProducts(page: number): Promise<PaginatedResponse<Product>> {
const res = await fetch(`https://api.example.com/products?page=${page}&pageSize=10`);
return res.json();
}
function ProductList() {
const [page, setPage] = useState(1);
const { data, isLoading, isFetching } = useQuery({
queryKey: ["products", page],
queryFn: () => fetchProducts(page),
placeholderData: (previousData) => previousData, // Keep previous data while fetching
});
const totalPages = data ? Math.ceil(data.total / data.pageSize) : 0;
return (
<div>
{isLoading && <div>Loading products...</div>}
<ul>
{data?.data.map((product) => (
<li key={product.id}>
{product.title} — ${product.price}
</li>
))}
</ul>
{isFetching && !isLoading && <div>Refreshing...</div>}
<div style={{ display: "flex", gap: "8px" }}>
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
Previous
</button>
<span>Page {page} of {totalPages}</span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={page >= totalPages}
>
Next
</button>
</div>
</div>
);
}
Expected output: Page 1 loads initially. Clicking Next loads page 2 while keeping page 1 data visible (via placeholderData). The "Refreshing..." indicator appears during page transitions. The previous data stays visible so the layout does not jump.
Infinite Scrolling
import { useInfiniteQuery } from "@tanstack/react-query";
import { useRef, useCallback } from "react";
interface InfiniteResponse<T> {
data: T[];
nextCursor?: number;
}
async function fetchInfinitePosts({ pageParam = 0 }): Promise<InfiniteResponse<Post>> {
const res = await fetch(`/api/posts?cursor=${pageParam}&limit=20`);
return res.json();
}
function InfinitePostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery({
queryKey: ["posts", "infinite"],
queryFn: fetchInfinitePosts,
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});
const observerRef = useRef<IntersectionObserver | null>(null);
const lastPostRef = useCallback(
(node: HTMLDivElement | null) => {
if (isFetchingNextPage) return;
if (observerRef.current) observerRef.current.disconnect();
observerRef.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasNextPage) {
fetchNextPage();
}
});
if (node) observerRef.current.observe(node);
},
[isFetchingNextPage, hasNextPage, fetchNextPage]
);
if (isLoading) return <div>Loading...</div>;
const posts = data?.pages.flatMap((page) => page.data) ?? [];
return (
<div>
{posts.map((post, index) => (
<div
key={post.id}
ref={index === posts.length - 1 ? lastPostRef : null}
>
<h3>{post.title}</h3>
<p>{post.body}</p>
</div>
))}
{isFetchingNextPage && <div>Loading more...</div>}
</div>
);
}
Expected output: Initial 20 posts load. Scrolling to the bottom triggers the intersection observer, which calls fetchNextPage. New posts append to the list. "Loading more..." appears during fetch. Cursor-based pagination continues until nextCursor is undefined.
Query Invalidation and Refetching
Keep data fresh by invalidating queries when related data changes.
import { useQueryClient } from "@tanstack/react-query";
function AdminPanel() {
const queryClient = useQueryClient();
async function handleClearCache() {
// Invalidate all queries
await queryClient.invalidateQueries();
}
async function handleRefreshUsers() {
// Invalidate only user queries
await queryClient.invalidateQueries({ queryKey: ["users"] });
}
async function handleRefetchActive() {
// Refetch all active queries
await queryClient.refetchQueries({ type: "active" });
}
return (
<div>
<button onClick={handleClearCache}>Clear All Cache</button>
<button onClick={handleRefreshUsers}>Refresh Users</button>
<button onClick={handleRefetchActive}>Refetch Active</button>
</div>
);
}
Common Mistakes
1. Not Setting staleTime
The default staleTime is 0 — every component mount refetches. Set staleTime to match how frequently your data changes.
2. Using enabled: false Without Manual Refetch
Queries with enabled: false do not fetch automatically. You must call refetch() or a dependency must change.
3. Mutating Cache Directly Without Optimistic Updates
Setting query data with setQueryData outside of onMutate is fragile. Always use optimistic update patterns with rollback.
Practice Questions
Challenge
Build a GitHub repository browser that: (1) shows repos for a searched organization with useQuery, (2) allows bookmarking repos with a mutation and optimistic update, (3) implements infinite scroll for issues within a repo using useInfiniteQuery, and (4) invalidates the repo list after adding a new repo.
Real-World Task
Add TanStack Query to an existing React app that uses useState/useEffect for data fetching. Replace all fetch calls with useQuery. Add a mutation with optimistic update for any create action. Configure staleTime and gcTime based on how often each data type changes. Remove all manual loading and error state management.
This tutorial is built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro — production applications where TanStack Query manages thousands of API requests with intelligent caching and real-time updates.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro