TanStack Query: The Complete Guide to Modern Data Fetching in React
Master TanStack Query v5 for React with caching strategies, optimistic updates, infinite queries, prefetching, and real-world patterns for production applications.
TanStack Query: The Complete Guide to Modern Data Fetching in React
Data fetching in React has always been painful — managing loading states, caching, deduplication, background refetching, and error handling manually leads to fragile, complex code. TanStack Query (formerly React Query) solves all of this with a declarative, cache-first approach. After using it extensively in multi-tenant web applications, I can say it's the single most impactful library you can add to a React project. Here's everything you need to know.
Why TanStack Query?
Before TanStack Query, a typical data fetching pattern looks like this:
// The old way — DON'T do this
function ProjectList() {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch('/api/projects')
.then((res) => res.json())
.then((data) => {
if (!cancelled) setProjects(data);
})
.catch((err) => {
if (!cancelled) setError(err);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
// ... render logic
}
With TanStack Query:
// The TanStack Query way
function ProjectList() {
const { data: projects, isLoading, error } = useQuery({
queryKey: ['projects'],
queryFn: () => fetch('/api/projects').then((res) => res.json()),
});
// That's it. Caching, deduplication, refetching — all handled.
}
Setup and Configuration
Provider Setup with Next.js App Router
// providers/QueryProvider.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // Data fresh for 1 minute
gcTime: 5 * 60 * 1000, // Cache persists for 5 minutes
retry: 2, // Retry failed requests twice
refetchOnWindowFocus: false, // Disable auto-refetch on focus
},
mutations: {
retry: 1,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Query Patterns
1. Type-Safe API Layer
Create a centralized, type-safe API layer:
// lib/api.ts
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '/api';
async function fetchAPI<T>(endpoint: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new ApiError(response.status, error.message || 'Request failed');
}
return response.json();
}
// Query functions
export const projectsApi = {
list: (params?: { status?: string; page?: number }) =>
fetchAPI<PaginatedResponse<Project>>('/projects', {
method: 'GET',
}),
getById: (id: string) =>
fetchAPI<Project>(`/projects/${id}`),
create: (data: CreateProjectDTO) =>
fetchAPI<Project>('/projects', {
method: 'POST',
body: JSON.stringify(data),
}),
update: (id: string, data: Partial<Project>) =>
fetchAPI<Project>(`/projects/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
delete: (id: string) =>
fetchAPI<void>(`/projects/${id}`, { method: 'DELETE' }),
};
2. Query Key Factory
Organize query keys for reliable cache invalidation:
// lib/queryKeys.ts
export const queryKeys = {
projects: {
all: ['projects'] as const,
lists: () => [...queryKeys.projects.all, 'list'] as const,
list: (filters: Record<string, unknown>) =>
[...queryKeys.projects.lists(), filters] as const,
details: () => [...queryKeys.projects.all, 'detail'] as const,
detail: (id: string) => [...queryKeys.projects.details(), id] as const,
},
users: {
all: ['users'] as const,
me: () => [...queryKeys.users.all, 'me'] as const,
detail: (id: string) => [...queryKeys.users.all, 'detail', id] as const,
},
};
3. Custom Query Hooks
// hooks/useProjects.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { projectsApi } from '@/lib/api';
import { queryKeys } from '@/lib/queryKeys';
export function useProjects(filters?: { status?: string; page?: number }) {
return useQuery({
queryKey: queryKeys.projects.list(filters ?? {}),
queryFn: () => projectsApi.list(filters),
placeholderData: (previousData) => previousData, // Keep previous data while fetching
});
}
export function useProject(id: string) {
return useQuery({
queryKey: queryKeys.projects.detail(id),
queryFn: () => projectsApi.getById(id),
enabled: !!id,
});
}
export function useCreateProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: projectsApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.lists() });
},
});
}
export function useUpdateProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Project> }) =>
projectsApi.update(id, data),
onSuccess: (updatedProject) => {
// Update detail cache directly
queryClient.setQueryData(
queryKeys.projects.detail(updatedProject.id),
updatedProject
);
// Invalidate list caches
queryClient.invalidateQueries({ queryKey: queryKeys.projects.lists() });
},
});
}
Advanced Patterns
Infinite Scroll with useInfiniteQuery
export function useInfiniteProjects(status?: string) {
return useInfiniteQuery({
queryKey: queryKeys.projects.list({ status, infinite: true }),
queryFn: ({ pageParam }) =>
projectsApi.list({ page: pageParam, status }),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
});
}
// Component usage
function InfiniteProjectList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteProjects();
const projects = data?.pages.flatMap((page) => page.data) ?? [];
return (
<>
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
)}
</>
);
}
Optimistic Updates with Rollback
export function useDeleteProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: projectsApi.delete,
onMutate: async (deletedId) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: queryKeys.projects.lists() });
// Snapshot previous value
const previousProjects = queryClient.getQueryData(
queryKeys.projects.list({})
);
// Optimistically remove from cache
queryClient.setQueryData(
queryKeys.projects.list({}),
(old: PaginatedResponse<Project> | undefined) =>
old
? { ...old, data: old.data.filter((p) => p.id !== deletedId) }
: old
);
return { previousProjects };
},
onError: (_err, _deletedId, context) => {
// Rollback on error
if (context?.previousProjects) {
queryClient.setQueryData(
queryKeys.projects.list({}),
context.previousProjects
);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.lists() });
},
});
}
Prefetching for Instant Navigation
// Prefetch on hover for instant page transitions
function ProjectCard({ project }: { project: Project }) {
const queryClient = useQueryClient();
const prefetchProject = () => {
queryClient.prefetchQuery({
queryKey: queryKeys.projects.detail(project.id),
queryFn: () => projectsApi.getById(project.id),
staleTime: 5 * 60 * 1000,
});
};
return (
<Link
href={`/projects/${project.id}`}
onMouseEnter={prefetchProject}
onFocus={prefetchProject}
>
<h3>{project.title}</h3>
</Link>
);
}
Dependent Queries
function UserProjects({ userId }: { userId: string }) {
// First, fetch the user
const { data: user } = useQuery({
queryKey: queryKeys.users.detail(userId),
queryFn: () => usersApi.getById(userId),
});
// Then, fetch their projects (only when user data is available)
const { data: projects } = useQuery({
queryKey: queryKeys.projects.list({ userId: user?.id }),
queryFn: () => projectsApi.list({ userId: user!.id }),
enabled: !!user?.id,
});
return (
<div>
<h2>{user?.name}'s Projects</h2>
{projects?.data.map((p) => <ProjectCard key={p.id} project={p} />)}
</div>
);
}
Server-Side Prefetching in Next.js
Hydrate queries on the server for instant-loading pages:
// app/projects/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { projectsApi } from '@/lib/api';
import { queryKeys } from '@/lib/queryKeys';
import { ProjectList } from './ProjectList';
export default async function ProjectsPage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: queryKeys.projects.list({}),
queryFn: () => projectsApi.list(),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProjectList />
</HydrationBoundary>
);
}
Key Takeaways
- Query key factories — Organize keys hierarchically for precise cache invalidation
- Type-safe API layer — Centralized fetch functions with proper error handling
- Custom hooks per resource — Encapsulate query configuration in reusable hooks
- Optimistic updates — Update UI instantly, rollback on failure
- Server prefetching — Hydrate queries on the server for zero-loading-state pages
- Prefetch on hover — Anticipate navigation for perceived instant transitions
TanStack Query eliminates the entire category of "data fetching state management" bugs. Once you adopt it, you'll wonder how you ever built React apps without it.