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.
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>
)
}
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