If you've ever written a React component that fetches data, you've probably written some version of this: a useEffect that calls an API, a useState for the response, another useState for loading, another for errors, and maybe some caching logic duct-taped on top. It works, but it's tedious, error-prone, and you end up rewriting the same plumbing in every component.
TanStack Query (previously known as React Query) exists to eliminate that entire category of boilerplate. It gives you a declarative, hook-based API for fetching, caching, and synchronizing server data — and it handles the hard parts (cache invalidation, background refetching, race conditions, retries) so you don't have to.
This guide covers TanStack Query v5 end-to-end, with TypeScript examples you can drop into a real project.
The Problem TanStack Query Solves
State management tools like Redux and Zustand work well for things the user controls directly — which modal is open, what's in a shopping cart, dark mode preferences. This is client state: it lives entirely in the browser and your code is the only thing that changes it.
Server state is a different animal. The data lives on a remote server. Other users can modify it while you're looking at it. You need network requests to read or write it, and those requests can fail, be slow, or return stale results. Most client-state libraries weren't designed with these challenges in mind, which is why fetching data with Redux always felt like so much ceremony.
TanStack Query treats server state as its own concern and provides purpose-built tools for it: automatic caching with configurable staleness, background refetching when users return to a tab, retry logic with exponential backoff, deduplication of identical requests, and much more — all without you writing a single line of caching code.
Notable Changes in v5
If you're coming from v4, here are the changes that'll affect your code most:
- Every hook now takes a single object argument — the multiple-argument overloads are gone, which makes the TypeScript experience much cleaner
- The
loadingstatus is now calledpending, andisLoadingis nowisPending cacheTimewas renamed togcTime(garbage collection time) to better describe what it actually controls- The
onSuccess,onError, andonSettledcallbacks were removed fromuseQuery— they caused subtle bugs when background refetches triggered them unexpectedly. Mutation hooks still have them. useSuspenseQueryis now a first-class hook (no more experimental flag) and guarantees thatdatais defined — noundefinedchecks needed- The new
queryOptionshelper lets you define a query configuration once and reuse it across hooks, prefetching, and cache reads with full type inference - Infinite queries gained a
maxPagesoption so you can cap how many pages are stored in memory - The bundle is roughly 20% smaller thanks to dropping IE11 support and using modern JavaScript features
Setting Up
Install the library and the (optional but highly recommended) devtools:
Terminal
npm install @tanstack/react-query @tanstack/react-query-devtools
Wrap your app in a QueryClientProvider. The QueryClient is where you configure defaults for all queries:
src/main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000, // data stays "fresh" for 1 minute
gcTime: 5 * 60_000, // inactive cache is garbage-collected after 5 min
retry: 3, // failed requests retry 3 times
refetchOnWindowFocus: true, // refetch stale data when the user returns to the tab
},
},
})
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
Tip: The devtools panel lets you inspect every cached query, see its status and data, and manually trigger refetches or removals. It's one of the best debugging tools in the React ecosystem — don't skip it.
Fetching Data with useQuery
The useQuery hook is the bread and butter of TanStack Query. You give it a query key (an array that uniquely identifies the data) and a query function (an async function that fetches it):
components/TodoList.tsx
import { useQuery } from '@tanstack/react-query'
interface Todo {
id: number
title: string
completed: boolean
}
async function fetchTodos(): Promise<Todo[]> {
const res = await fetch('https://api.example.com/todos')
if (!res.ok) throw new Error('Request failed')
return res.json()
}
function TodoList() {
const { data, error, isPending, isFetching, isError } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 5 * 60_000,
select: (todos) => todos.filter(t => !t.completed),
})
if (isPending) return <p>Loading...</p>
if (isError) return <p>Something went wrong: {error.message}</p>
return (
<>
<ul>
{data.map(todo => <li key={todo.id}>{todo.title}</li>)}
</ul>
{isFetching && <small>Refreshing...</small>}
</>
)
}
A few things to notice: isPending is true only when there's no cached data yet and we're still fetching. isFetching is true during any fetch, including silent background refetches. And the select option lets you transform data before it reaches your component — TanStack Query memoizes the result, so your component only re-renders when the filtered output actually changes.
Sharing Query Definitions with queryOptions
In a real app, you'll want to reuse the same query configuration across multiple components, in prefetching calls, and when reading from the cache directly. The queryOptions helper makes this type-safe and DRY:
queries/todos.ts
import { queryOptions } from '@tanstack/react-query'
export const todoListOptions = () =>
queryOptions({
queryKey: ['todos'] as const,
queryFn: fetchTodos,
staleTime: 5 * 60_000,
})
export const todoDetailOptions = (id: number) =>
queryOptions({
queryKey: ['todos', id] as const,
queryFn: () => fetchTodoById(id),
})
// Now usable everywhere with full type inference:
const { data } = useQuery(todoListOptions()) // in a component
await queryClient.prefetchQuery(todoListOptions()) // in a loader
const cached = queryClient.getQueryData(todoListOptions().queryKey) // cache read
Writing Data with Mutations
Queries read data; mutations write it. The useMutation hook handles POST, PUT, PATCH, and DELETE operations. The most common pattern is to invalidate related queries after a successful mutation so the UI stays in sync:
hooks/useCreateTodo.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
function useCreateTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (newTodo: { title: string }) => {
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() as Promise<Todo>
},
onSuccess: () => {
// Tell TanStack Query that the todo list is outdated
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
}
// In a component:
function AddTodoForm() {
const { mutate, isPending } = useCreateTodo()
return (
<form onSubmit={(e) => {
e.preventDefault()
const title = new FormData(e.currentTarget).get('title') as string
mutate({ title })
}}>
<input name="title" required />
<button disabled={isPending}>
{isPending ? 'Saving...' : 'Add'}
</button>
</form>
)
}
Making the UI Feel Instant with Optimistic Updates
Invalidation works, but there's a perceptible delay while the server processes the request and the refetch completes. For interactions where speed matters — adding an item to a list, toggling a checkbox, liking a post — you can update the cache immediately and roll back if the server rejects it:
hooks/useCreateTodoOptimistic.ts
function useCreateTodoOptimistic() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createTodo,
onMutate: async (newTodo) => {
// Stop any in-flight refetches from overwriting our optimistic data
await queryClient.cancelQueries({ queryKey: ['todos'] })
// Save the current state in case we need to undo
const snapshot = queryClient.getQueryData<Todo[]>(['todos'])
// Write the optimistic value into the cache immediately
queryClient.setQueryData<Todo[]>(['todos'], (prev) => [
...(prev ?? []),
{ id: Math.random(), ...newTodo, completed: false },
])
return { snapshot }
},
onError: (_err, _vars, context) => {
// Something went wrong — restore the previous data
if (context?.snapshot) {
queryClient.setQueryData(['todos'], context.snapshot)
}
},
onSettled: () => {
// Whether it succeeded or failed, refetch to get the real server state
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
}
The pattern has four steps: cancel competing refetches, snapshot the current cache, write optimistic data, and restore the snapshot on failure. The onSettled callback always fires a real refetch at the end, so even if the optimistic ID doesn't match the server's ID, the cache converges to the correct state.
Query Invalidation Strategies
Cache invalidation is often called one of the hardest problems in computer science. TanStack Query makes it manageable with a flexible matching system:
Invalidation patterns
const queryClient = useQueryClient()
// Prefix match (default) — invalidates ['todos'], ['todos', 1], ['todos', { status }]
queryClient.invalidateQueries({ queryKey: ['todos'] })
// Exact match — only invalidates the ['todos'] key, not ['todos', 1]
queryClient.invalidateQueries({ queryKey: ['todos'], exact: true })
// Custom predicate for complex logic
queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === 'todos' &&
(query.queryKey[1] as number) > 100,
})
// Mark stale without triggering a refetch
queryClient.invalidateQueries({
queryKey: ['todos'],
refetchType: 'none',
})
A practical tip: structure your query keys like a file path. Put the broadest category first and get more specific: ['todos', 'list', { filters }] and ['todos', 'detail', todoId]. That way you can invalidate everything todo-related with ['todos'], or just the list with ['todos', 'list'].
Infinite Scrolling and Load-More Pagination
Cursor-based pagination — the "load more" button or infinite scroll pattern — has dedicated support through useInfiniteQuery:
components/ProjectFeed.tsx
import { useInfiniteQuery } from '@tanstack/react-query'
interface Page {
items: Project[]
nextCursor: string | null
}
function ProjectFeed() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['projects'],
queryFn: async ({ pageParam }): Promise<Page> => {
const res = await fetch(`/api/projects?cursor=${pageParam}&limit=20`)
return res.json()
},
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.nextCursor,
maxPages: 5, // keep at most 5 pages in memory
})
return (
<>
{data?.pages.flatMap(page =>
page.items.map(project => (
<ProjectCard key={project.id} project={project} />
))
)}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? 'Loading...' : 'Load more'}
</button>
)}
</>
)
}
The maxPages option (new in v5) is worth knowing about — in a long infinite scroll, you might accumulate hundreds of pages in memory. Setting a cap means old pages are dropped and re-fetched if the user scrolls back up.
Server-Side Rendering with Next.js
TanStack Query works with Next.js App Router through a prefetch-and-hydrate pattern. You fetch data in a Server Component, dehydrate the cache into serializable state, and pass it to the client where it's rehydrated — the client component gets the data instantly without a loading state.
app/get-query-client.ts
import { QueryClient } from '@tanstack/react-query'
import { cache } from 'react'
// React's cache() ensures one QueryClient per server request
// On the client, it's created once and reused
const getQueryClient = cache(
() => new QueryClient({
defaultOptions: {
queries: { staleTime: 60_000 },
},
})
)
export default getQueryClient
app/posts/page.tsx (Server Component)
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import getQueryClient from '@/app/get-query-client'
import PostList from './post-list'
export default async function PostsPage() {
const queryClient = getQueryClient()
// This runs on the server — the data is ready before any HTML is sent
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: async () => {
const res = await fetch('https://api.example.com/posts')
return res.json()
},
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostList />
</HydrationBoundary>
)
}
app/posts/post-list.tsx (Client Component)
'use client'
import { useSuspenseQuery } from '@tanstack/react-query'
export default function PostList() {
// The cache already has this data from the server prefetch.
// No loading spinner, no layout shift — it's just there.
const { data: posts } = useSuspenseQuery({
queryKey: ['posts'],
queryFn: async () => {
const res = await fetch('https://api.example.com/posts')
return res.json()
},
})
return (
<ul>
{posts.map((post: { id: number; title: string }) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
One critical detail: set staleTime to something greater than zero on the server-side QueryClient. If you leave it at the default (zero), the client will consider the prefetched data stale immediately and fire a redundant refetch the moment it mounts — defeating the purpose of SSR.
How the Cache Actually Works
TanStack Query uses a strategy called stale-while-revalidate. Two timers control it:
staleTime— how long data is considered fresh after fetching. While it's fresh, TanStack Query returns the cached value and skips the network entirely. After this window, the data is "stale" and eligible for a background refetch.gcTime— how long unused data sticks around after the last component that was watching it unmounts. Once this timer runs out, the cache entry is deleted entirely.
The user experience this creates: you navigate to a page, the data loads. You navigate away and come back — the cached data appears instantly (no spinner), while a background request quietly checks for updates. If the data changed on the server, the UI updates seamlessly. If it didn't, nothing visible happens.
Keep gcTime greater than or equal to staleTime. If you set gcTime shorter, the cache entry might get deleted before the staleness window is up, and you'll get a full loading state instead of the instant stale-data-then-update experience.
Comparison with Alternatives
The three libraries you'll most often see compared:
SWR is Vercel's data-fetching library. It covers the basics well with a smaller API surface, but lacks built-in devtools, garbage collection, infinite query primitives, and the kind of granular cache control TanStack Query provides. It's React-only.
RTK Query makes sense if your project already depends on Redux. It has a unique "API slice" pattern where you define all endpoints upfront and mutations automatically invalidate related queries by tag. The trade-off is that you're buying into the Redux ecosystem.
Plain useEffect + fetch is what most tutorials teach, and it's fine for a demo, but it doesn't handle caching, deduplication, retries, race conditions, background refetching, or any of the other concerns that matter in production. You'd end up rebuilding a worse version of TanStack Query.
TanStack Query is the most full-featured option, works across React, Vue, Solid, Svelte, and Angular, and has official devtools that are genuinely useful. For most React applications that talk to a server, it's the strongest default choice.
Mistakes That Bite People
- Copying query data into Redux or Context. TanStack Query already is your server-state cache. Duplicating the data into another store creates two sources of truth that inevitably fall out of sync.
- Leaving
staleTimeat zero without understanding the implications. The default of zero means "always stale," which triggers refetches on every mount, tab focus, and reconnect. That's deliberate, but it surprises people. Set a sensible default at the QueryClient level. - Accidentally creating request waterfalls. If a parent component fetches user data and a child component fetches the user's posts, those requests run sequentially. Prefetch both in a route loader, or use
useQueriesto run them in parallel. - Forgetting to cancel queries in optimistic updates. If you skip the
cancelQueriescall inonMutate, a background refetch that was already in flight can land and overwrite your optimistic data with the old server state. - SSR without
staleTime. As mentioned above, the client will immediately re-request data that the server just fetched. AstaleTimeof even 10 seconds prevents this.
Resources
- Official docs — the primary reference, well-written and comprehensive
- TypeScript guide — covers type inference, generics, and the
queryOptionshelper - Advanced SSR guide — deep dive into the Next.js App Router integration
- Important defaults — explains every default setting and why it was chosen
- Feature comparison table — official side-by-side with SWR, RTK Query, and Apollo
- GitHub repository
