TanStack Query: Data Fetching for React
If you've ever wrestled with managing loading states, caching API responses, or keeping your UI in sync with a remote server, TanStack Query (formerly React Query) is the library that makes all of that feel effortless. It fundamentally changes how you think about server state in React — separating it from client state and giving you intelligent caching, background refetching, and synchronization out of the box.
In this guide, we'll go from zero to production-ready with TanStack Query v5, covering everything from basic data fetching to optimistic updates, infinite scroll, and cache invalidation strategies. Whether you're migrating from manual useEffect fetching or evaluating it against SWR or RTK Query, you'll leave with a complete understanding of when and how to use it effectively.
TanStack Query has over 40,000 GitHub stars and is downloaded more than 4 million times per week. It's the industry standard for server state management in React, used by companies like Vercel, Shopify, and Netflix. Version 5 (released in late 2023) brought a streamlined API, improved TypeScript support, and first-class support for streaming data.
What is TanStack Query?
TanStack Query is an asynchronous state management library for React (and now Vue, Solid, Angular, and Svelte via framework adapters). It solves a problem that most libraries ignore: server state is fundamentally different from client state.
Client state (think UI toggles, form values, selected tabs) lives in your app and you control it fully. Server state, on the other hand, lives on a remote server. You don't own it, it can change without your knowledge, and fetching it is asynchronous. TanStack Query is purpose-built for managing this complexity:
- Automatic caching: Responses are cached by query key and reused across components
- Background refetching: Stale data is refetched when users revisit a page or refocus the window
- Deduplication: Duplicate requests for the same data are collapsed into one network call
- Pagination & infinite scroll: First-class primitives for paginated and cursor-based APIs
- Mutations with rollback: Optimistic updates with automatic rollback on failure
- DevTools: A visual inspector showing every query's status and cached data
TanStack Query sits between your components and the network, managing cache, background refetching, and state
Installation & Setup
TanStack Query v5 requires React 18 or later and has zero required dependencies beyond React itself. Setup takes under five minutes.
Install the package
Install @tanstack/react-query and optionally the DevTools package for debugging.
# npm
npm install @tanstack/react-query
# With DevTools (recommended for development)
npm install @tanstack/react-query @tanstack/react-query-devtools
# yarn / pnpm
yarn add @tanstack/react-query @tanstack/react-query-devtools
pnpm add @tanstack/react-query @tanstack/react-query-devtools
Wrap your app with QueryClientProvider
Create a QueryClient instance and provide it at the root of your application.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import ReactDOM from 'react-dom/client'
import App from './App'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes — data stays fresh before refetch
retry: 2, // retry failed requests twice
refetchOnWindowFocus: true, // refetch when user returns to tab
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
Start fetching data with useQuery
That's all the setup you need. Now any component in your tree can use useQuery to fetch and cache data.
staleTime controls how long cached data is considered fresh (no network request made). gcTime (formerly cacheTime) controls how long unused cached data is kept in memory before garbage collection. They serve different purposes — tune staleTime first.
Querying Data with useQuery
The useQuery hook is the core of TanStack Query. It takes a query key (used for cache identification) and a query function (any async function returning a promise), and manages all the loading, error, and success states for you.
import { useQuery } from '@tanstack/react-query'
interface Todo {
id: number
title: string
completed: boolean
userId: number
}
async function fetchTodos(): Promise<Todo[]> {
const res = await fetch('https://jsonplaceholder.typicode.com/todos')
if (!res.ok) throw new Error('Failed to fetch todos')
return res.json()
}
function TodoList() {
const {
data: todos,
isLoading,
isError,
error,
isFetching, // true when background refetch is happening
refetch,
} = useQuery({
queryKey: ['todos'], // unique cache key — array format preferred
queryFn: fetchTodos,
staleTime: 1000 * 60 * 2, // override default: 2 minutes fresh
})
if (isLoading) return <p>Loading todos...</p>
if (isError) return <p>Error: {error.message}</p>
return (
<div>
<header>
<h2>Todos {isFetching && '(updating...)'}</h2>
<button onClick={() => refetch()}>Refresh</button>
</header>
<ul>
{todos?.map(todo => (
<li key={todo.id} style={{ opacity: todo.completed ? 0.5 : 1 }}>
{todo.title}
</li>
))}
</ul>
</div>
)
}
Dynamic Query Keys
Query keys can include variables, which automatically re-trigger the query when those variables change. This is the pattern for fetching a specific resource by ID, or filtering results.
// Fetch a single user — reruns when userId changes
function useUser(userId: number) {
return useQuery({
queryKey: ['users', userId], // key includes the variable
queryFn: () => fetchUser(userId),
enabled: !!userId, // skip if userId is falsy
})
}
// Filtered list — reruns when filter changes
function useTodos(filter: 'all' | 'done' | 'pending') {
return useQuery({
queryKey: ['todos', { filter }],
queryFn: () => fetchTodos(filter),
})
}
// Dependent query — waits for a previous query to finish
function useUserPosts(userId?: number) {
return useQuery({
queryKey: ['posts', { userId }],
queryFn: () => fetchPostsByUser(userId!),
enabled: !!userId, // won't run until userId is available
})
}
// Usage: chain two queries
function UserWithPosts({ userId }: { userId: number }) {
const { data: user } = useUser(userId)
const { data: posts } = useUserPosts(user?.id) // enabled only after user loads
// ...
}
Wrap useQuery calls in custom hooks (like useUser above). This co-locates your query key and fetcher, makes the hook reusable across components, and keeps component code clean. It's the recommended pattern for any real application.
Mutations with useMutation
While useQuery handles reading data, useMutation handles write operations — POST, PUT, PATCH, DELETE. After a successful mutation, you typically invalidate related queries so they refetch with fresh data.
import { useMutation, useQueryClient } from '@tanstack/react-query'
async function createTodo(newTodo: { title: string }): Promise<Todo> {
const res = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo),
})
if (!res.ok) throw new Error('Failed to create todo')
return res.json()
}
function AddTodoForm() {
const queryClient = useQueryClient()
const [title, setTitle] = useState('')
const mutation = useMutation({
mutationFn: createTodo,
// Called immediately on success — before the response propagates
onSuccess: (newTodo) => {
// Invalidate and refetch the todos list
queryClient.invalidateQueries({ queryKey: ['todos'] })
// OR: manually update the cache without a network request
queryClient.setQueryData<Todo[]>(['todos'], (old = []) => [
...old,
newTodo,
])
},
onError: (error) => {
console.error('Create failed:', error)
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim()) return
mutation.mutate({ title })
setTitle('')
}
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="New todo..."
disabled={mutation.isPending}
/>
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Adding...' : 'Add Todo'}
</button>
{mutation.isError && (
<p style={{ color: 'red' }}>Error: {mutation.error.message}</p>
)}
</form>
)
}
Optimistic Updates
Optimistic updates show the result of a mutation immediately — before the server confirms it — giving users instant feedback. If the mutation fails, TanStack Query rolls back the cache to its previous state automatically.
const toggleTodo = useMutation({
mutationFn: ({ id, completed }: { id: number; completed: boolean }) =>
fetch(`/api/todos/${id}`, {
method: 'PATCH',
body: JSON.stringify({ completed }),
}).then(r => r.json()),
onMutate: async ({ id, completed }) => {
// Cancel any outgoing refetches so they don't overwrite optimistic update
await queryClient.cancelQueries({ queryKey: ['todos'] })
// Snapshot current value for rollback
const previousTodos = queryClient.getQueryData<Todo[]>(['todos'])
// Optimistically update cache
queryClient.setQueryData<Todo[]>(['todos'], old =>
old?.map(todo => (todo.id === id ? { ...todo, completed } : todo)) ?? []
)
// Return snapshot for onError rollback
return { previousTodos }
},
onError: (_err, _vars, context) => {
// Roll back to snapshot if mutation fails
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos)
}
},
onSettled: () => {
// Always refetch to ensure cache is in sync with server
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
Advanced Features
Use keepPreviousData (v5: placeholderData: keepPreviousData) for smooth page transitions — the old page stays visible while the new one loads.
import { keepPreviousData } from '@tanstack/react-query'
function PaginatedList() {
const [page, setPage] = useState(1)
const { data, isFetching } = useQuery({
queryKey: ['users', page],
queryFn: () => fetchUsers({ page, limit: 10 }),
placeholderData: keepPreviousData, // show old data while fetching new page
})
return (
<div>
<ul>
{data?.users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
<nav>
<button onClick={() => setPage(p => p - 1)} disabled={page === 1}>
Previous
</button>
<span>Page {page}</span>
<button
onClick={() => setPage(p => p + 1)}
disabled={!data?.hasMore}
>
{isFetching ? 'Loading...' : 'Next'}
</button>
</nav>
</div>
)
}
useInfiniteQuery is designed for "load more" and infinite scroll patterns. Each page's data is stored separately but returned as a flattened structure.
import { useInfiniteQuery } from '@tanstack/react-query'
import { useInView } from 'react-intersection-observer'
function InfiniteList() {
const { ref, inView } = useInView()
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['infinite-users'],
queryFn: ({ pageParam }) => fetchUsers({ cursor: pageParam }),
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
})
// Auto-fetch next page when sentinel div enters viewport
useEffect(() => {
if (inView && hasNextPage) fetchNextPage()
}, [inView, hasNextPage])
return (
<div>
{data?.pages.map((page, i) => (
<Fragment key={i}>
{page.users.map(user => (
<div key={user.id}>{user.name}</div>
))}
</Fragment>
))}
{/* Sentinel element — triggers load when visible */}
<div ref={ref}>
{isFetchingNextPage && 'Loading more...'}
{!hasNextPage && 'No more users'}
</div>
</div>
)
}
Multiple useQuery calls in the same component run in parallel automatically. For dynamic numbers of parallel queries, use useQueries.
// Two queries fire in parallel — no extra setup needed
function Dashboard() {
const usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers })
const statsQuery = useQuery({ queryKey: ['stats'], queryFn: fetchStats })
if (usersQuery.isLoading || statsQuery.isLoading) return <Spinner />
return (
<div>
<StatsPanel data={statsQuery.data} />
<UserTable data={usersQuery.data} />
</div>
)
}
// Dynamic number of parallel queries with useQueries
function MultiUserDetails({ userIds }: { userIds: number[] }) {
const results = useQueries({
queries: userIds.map(id => ({
queryKey: ['users', id],
queryFn: () => fetchUser(id),
})),
})
const allLoaded = results.every(r => r.isSuccess)
return allLoaded ? (
<ul>
{results.map((r, i) => (
<li key={userIds[i]}>{r.data?.name}</li>
))}
</ul>
) : <Spinner />
}
Prefetch data before a user navigates to a route. When they arrive, the data is already cached and renders instantly with no loading state.
// Prefetch on hover — data is ready when user clicks
function NavLink({ userId }: { userId: number }) {
const queryClient = useQueryClient()
const prefetchUser = () => {
queryClient.prefetchQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
staleTime: 1000 * 60, // don't refetch if already fresh
})
}
return (
<a
href={`/users/${userId}`}
onMouseEnter={prefetchUser} // prefetch on hover
>
View User
</a>
)
}
// Server-side prefetching with Next.js App Router
// app/users/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
export default async function UsersPage() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: ['users'],
queryFn: fetchUsers,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<UserList /> {/* renders with data already in cache */}
</HydrationBoundary>
)
}
TanStack Query vs Alternatives
Choosing the right data fetching solution depends on your stack and requirements. Here's how the major options compare:
| Feature | TanStack Query v5 | SWR | RTK Query |
|---|---|---|---|
| Bundle size (minzipped) | ~13 KB | ~4 KB | ~9 KB (part of RTK) |
| Mutations | First-class useMutation |
Manual (trigger fn) | First-class endpoints |
| Infinite queries | Built-in useInfiniteQuery |
Built-in useSWRInfinite |
Manual with serializeQueryArgs |
| Cache granularity | Per query key (flexible) | Per URL key | Per endpoint + args |
| Optimistic updates | Built-in with rollback | Via mutate(data, false) |
Built-in via onQueryStarted |
| DevTools | Excellent visual inspector | None official | Redux DevTools integration |
| Framework support | React, Vue, Solid, Angular | React only | React (via Redux) |
| Best for | Complex apps, multiple frameworks | Simple apps, minimal boilerplate | Redux-heavy codebases |
TanStack Query wins on features and DevTools. SWR wins on bundle size. RTK Query wins if you're already deep in Redux. Most new projects should default to TanStack Query.
Error Handling & Retry Logic
TanStack Query retries failed queries automatically (3 times by default) with exponential backoff. You can customize this globally or per-query, and use error boundaries to catch and display errors declaratively.
// Per-query retry configuration
const { data, error } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
retry: (failureCount, error) => {
// Don't retry on 404 or 401
if (error.status === 404 || error.status === 401) return false
return failureCount < 3
},
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
})
// Global error handling with QueryCache
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
if (query.state.data !== undefined) {
// Only show toast if we had data — background refetch failure
toast.error(`Background refresh failed: ${error.message}`)
}
},
}),
mutationCache: new MutationCache({
onError: (error) => {
toast.error(`Action failed: ${error.message}`)
},
}),
})
// Error boundary for declarative error handling
import { QueryErrorResetBoundary } from '@tanstack/react-query'
import { ErrorBoundary } from 'react-error-boundary'
function App() {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary }) => (
<div>
<p>Something went wrong!</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
)}
>
<UserList />
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
)
}
Best Practices
Query Key Conventions
- Use arrays for all query keys:
['users'],['users', id],['users', { status: 'active' }] - Put the most general identifiers first, specific last — this makes selective invalidation easy
- Extract key factories to avoid typos and ease invalidation:
userKeys.detail(id)
// Define all keys in one place
const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: object) => [...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: number) => [...userKeys.details(), id] as const,
}
// Usage — typo-safe, easy to invalidate
const { data } = useQuery({
queryKey: userKeys.detail(userId), // ['users', 'detail', 123]
queryFn: () => fetchUser(userId),
})
// Invalidate all user queries after a bulk action
queryClient.invalidateQueries({ queryKey: userKeys.all })
// Invalidate only a specific user's detail
queryClient.invalidateQueries({ queryKey: userKeys.detail(42) })
Don't put useQuery inside conditionals or loops — it violates the Rules of Hooks and causes issues. For conditional fetching, use the enabled option. For dynamic numbers of queries, use useQueries.
QueryClient Configuration Reference
| Option | Default | Recommendation |
|---|---|---|
staleTime |
0 (always stale) | Set to 1–5 min for most resources |
gcTime |
5 minutes | Leave default; increase for rarely changing data |
retry |
3 | Lower to 1–2 for user-triggered actions |
refetchOnWindowFocus |
true | Keep true for real-time-ish data; disable for static |
refetchOnReconnect |
true | Keep true — users expect fresh data after reconnect |
refetchInterval |
false | Set per-query for polling (e.g., live dashboards) |
Conclusion
TanStack Query doesn't replace your global state manager — it eliminates the need for one when it comes to server data. By letting the library handle caching, background refetching, deduplication, and error states, you get to write significantly less code while delivering a better user experience.
The key mental shift is recognising that your cache is your server state. You don't need to setIsLoading(true), manually clear stale data, or coordinate async updates across components. TanStack Query handles all of that, and the DevTools make it transparent.
Key Takeaways
- Separate server state from client state — TanStack Query owns the former, useState/Zustand owns the latter
- Use the query key factory pattern to centralise cache keys and simplify invalidation
- Set a realistic staleTime — the default of 0 causes more network requests than you need
- Wrap useQuery in custom hooks — co-locate the key, fetcher, and options in one reusable place
- Optimistic updates + onError rollback make mutations feel instant without sacrificing data integrity
- Prefetch on hover for navigation links — pages load instantly when the user clicks