TL;DR: TanStack Query manages server state in React.
useQuery()fetches and caches data;useMutation()handles writes with optimistic updates. Automatic background refetches, stale-while-revalidate caching, and DevTools included. ~13kB. Official docs: tanstack.com/query.
What is TanStack Query?
TanStack Query (formerly React Query) manages server state - data that lives on a server and needs to be fetched, cached, synchronized, and updated. It handles the lifecycle that every data-fetching implementation reinvents: loading states, error states, background refetches, cache invalidation, and optimistic updates.
The key insight: your component shouldn't manage fetching logic. useQuery subscribes to a cache entry; TanStack Query handles the rest.
I ripped out roughly 40 custom useEffect data-fetching hooks from a dashboard app when I adopted this library, and the bug reports about stale data after navigation basically stopped overnight. Turns out most of those bugs weren't logic errors, they were cache-invalidation problems nobody had actually solved, just papered over with key props that forced remounts.
When to use it
Use TanStack Query when your app fetches data from an API. It replaces:
useEffect+useState+ manual loading/error flags- Redux/Zustand stores used purely as an API response cache
- Custom data-fetching hooks that don't handle staleness
Skip it for client-only state (UI toggles, form state, wizard steps) - use Zustand or Jotai instead.
Key Features
- Automatic caching - responses cached by query key; deduplicates parallel requests
- Stale-while-revalidate - shows cached data immediately, refetches in background
- Window focus refetching - refetches when the user returns to the tab
- Mutations -
useMutationfor POST/PUT/DELETE with optimistic updates - Infinite queries -
useInfiniteQueryfor pagination and infinite scroll - DevTools - visual inspector for cache state, query status, and timing
Installation
npm install @tanstack/react-query
# DevTools (dev only):
npm install @tanstack/react-query-devtools
Basic Usage
// 1. Wrap your app
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
function App() {
return (
<QueryClientProvider client={queryClient}>
<MyApp />
</QueryClientProvider>
)
}
// 2. Fetch data in any component
import { useQuery } from '@tanstack/react-query'
function UserProfile({ userId }: { userId: string }) {
const { data, isPending, isError } = useQuery({
queryKey: ['users', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
})
if (isPending) return <Skeleton />
if (isError) return <ErrorMessage />
return <Profile user={data} />
}
Mutations with Cache Invalidation
import { useMutation, useQueryClient } from '@tanstack/react-query'
function DeleteButton({ postId }: { postId: string }) {
const queryClient = useQueryClient()
const deleteMutation = useMutation({
mutationFn: (id: string) => fetch(`/api/posts/${id}`, { method: 'DELETE' }),
onSuccess: () => {
// Re-fetch the posts list after deletion
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})
return (
<button
onClick={() => deleteMutation.mutate(postId)}
disabled={deleteMutation.isPending}
>
Delete
</button>
)
}
Infinite Queries for Pagination
useInfiniteQuery is the one I reach for most on feed-style UIs, comment threads, anything with a "load more" button or scroll-triggered pagination. It tracks page cursors for you instead of you juggling an array of page numbers in component state:
import { useInfiniteQuery } from '@tanstack/react-query'
function CommentFeed({ postId }: { postId: string }) {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['comments', postId],
queryFn: ({ pageParam }) =>
fetch(`/api/posts/${postId}/comments?cursor=${pageParam}`).then(r => r.json()),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
})
return (
<div>
{data?.pages.map((page) => page.comments.map((c: Comment) => (
<CommentRow key={c.id} comment={c} />
)))}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? 'Loading...' : 'Load more'}
</button>
)}
</div>
)
}
The cache stores each page separately, so invalidateQueries(['comments', postId]) after a new comment is posted refetches only the affected pages, not the whole feed the user already scrolled past.
TypeScript Tips
Type your query functions for full inference:
interface Post { id: string; title: string; body: string }
const { data } = useQuery({
queryKey: ['posts'],
queryFn: (): Promise<Post[]> => fetch('/api/posts').then(r => r.json()),
})
// data is Post[] | undefined - automatically inferred
Common Gotchas
queryKeymust be serializable - use primitive values or plain objects; avoid class instances or functions in the keystaleTimedefaults to 0 - every mount triggers a background refetch. SetstaleTime: 5 * 60 * 1000globally for data that doesn't change often- Parallel queries - use
useQueriesfor a dynamic list of queries rather than callinguseQueryin a loop - Error boundaries - set
throwOnError: trueon theQueryClientto let errors bubble to React Error Boundaries
Related
- Zustand - use Zustand for client UI state alongside TanStack Query for server state
- Recharts - pass
useQuerydata directly to Recharts chart components for live-updating dashboards with automatic background refetches - React 19 Guide - React 19's
use()hook and Actions complement TanStack Query for server-first patterns - TypeScript Generics Guide -
useQuery<TData, TError>anduseMutationare fully generic; understand the patterns here