Why you need it?
You’d use TanStack Query (formerly React Query) in your React or Next.js app anytime you need to fetch, cache, and update server state — i.e., data that comes from an API or backend service and isn’t just local UI state.
What TanStack Query does (in one line)
It manages server state for you—fetching, caching, refetching, background updates, mutations, and synchronization—so you can focus on UI and business logic.
Install & basic setup
npm i @tanstack/react-query # (optional devtools) npm i @tanstack/react-query-devtools
Create a QueryClient once and wrap your app:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' const queryClient = new QueryClient() export default function App({ children }) { return ( <QueryClientProvider client={queryClient}> {children} </QueryClientProvider> ) }
This gives your components access to the cache and all hooks. (Quick Start shows the same flow.)
Core primitives
1) Queries (read data)
A query is a “declarative dependency” on async data, uniquely identified by a query key. You subscribe with useQuery.
import { useQuery } from '@tanstack/react-query' function Users() { const { data, isLoading, isError } = useQuery({ queryKey: ['users'], queryFn: () => fetch('/api/users').then(r => r.json()), staleTime: 60_000, // data considered fresh for 1 min }) // ... }
Key ideas:
- queryKey must be stable (e.g., ['user', id]), as it’s the cache address.
- staleTime controls freshness; background refetches happen when data is stale or on triggers (focus, network reconnect) unless you opt out.
2) Mutations (write data)
Use useMutation for POST/PUT/PATCH/DELETE. Invalidate or update cached queries on success.
import { useMutation, useQueryClient } from '@tanstack/react-query' function AddUser() { const qc = useQueryClient() const addUser = useMutation({ mutationFn: (body: any) => fetch('/api/users', { method: 'POST', body: JSON.stringify(body) }) .then(r => r.json()), onSuccess: () => { qc.invalidateQueries({ queryKey: ['users'] }) // refetch list }, }) // addUser.mutate({ name: 'Ada' }) }
3) Invalidation & cache lifecycle
- Invalidate when underlying data may have changed (queryClient.invalidateQueries).
- gcTime (garbage-collection time) controls how long unused caches live in memory; Infinity disables GC. (v5 reference also notes this under mutation options.)
Everyday patterns you’ll reach for
Parallel queries
const users = useQuery({ queryKey: ['users'], queryFn: fetchUsers }) const stats = useQuery({ queryKey: ['stats'], queryFn: fetchStats })
Both run independently; each manages its own loading/error state.
Dependent queries
const user = useQuery({ queryKey: ['user', id], queryFn: () => getUser(id) }) const posts = useQuery({ queryKey: ['posts', user.data?.id], queryFn: () => getPosts(user.data!.id), enabled: !!user.data, // wait until user is loaded })
Enable a query only when its dependency is available.
Pagination & infinite scrolling
Use useQuery with page params, or useInfiniteQuery for “Load more” UIs—same cache/key principles apply.
Optimistic updates
Use onMutate to update cached lists immediately; roll back on error; invalidate on success to re-sync.
Query keys: the golden rule
Keys are arrays that deterministically represent the resource. Good keys = predictable caching and refetching:
- ['todos'] for the collection
- ['todo', id] for an item
- ['todos', { page, filters }] for paginated/filterable data
The docs emphasize stable keys to avoid cache misses or re-renders. (The ESLint rules reinforce this too.)
DevTools
Add DevTools in development to see cache contents and refetch status.
import { ReactQueryDevtools } from '@tanstack/react-query-devtools' <QueryClientProvider client={queryClient}> {children} <ReactQueryDevtools initialIsOpen={false} /> </QueryClientProvider>
Helps you debug queries/mutations and cache state.
SSR, SSG & Next.js (App Router & RSC)
For server rendering you typically:
- Prefetch queries on the server,
- Dehydrate the cache into the HTML,
- Hydrate on the client to avoid a second fetch.
See Server Rendering & Hydration for the basic flow, and Advanced Server Rendering for Next.js App Router, streaming, and Server Components considerations.
Linting best practices (optional but recommended)
Use @tanstack/eslint-plugin-query to catch unstable keys, prefer object syntax, and other pitfalls. Add plugin:@tanstack/query/recommended to your ESLint config. (Latest plugin is v5 on npm.)
Small but mighty options you’ll set often
- staleTime: how long data is “fresh” (skip refetch on focus).
- refetchOnWindowFocus / refetchOnReconnect: toggle auto-sync behavior.
- retry / retryDelay: control error retries.
- enabled: conditionally run a query.
- select: transform data before it hits your component.
Minimal “real” example
import { QueryClient, QueryClientProvider, useQuery, useMutation, useQueryClient } from '@tanstack/react-query' const queryClient = new QueryClient() function Todos() { const qc = useQueryClient() const todos = useQuery({ queryKey: ['todos'], queryFn: () => fetch('/api/todos').then(r => r.json()), staleTime: 30_000, }) const addTodo = useMutation({ mutationFn: (title: string) => fetch('/api/todos', { method: 'POST', body: JSON.stringify({ title }) }) .then(r => r.json()), onSuccess: () => qc.invalidateQueries({ queryKey: ['todos'] }), }) if (todos.isLoading) return <p>Loading…</p> if (todos.isError) return <p>Failed to load</p> return ( <> <ul>{todos.data.map((t: any) => <li key={t.id}>{t.title}</li>)}</ul> <button onClick={() => addTodo.mutate('New Task')}>Add</button> </> ) } export default function App() { return ( <QueryClientProvider client={queryClient}> <Todos /> </QueryClientProvider> ) }
This example demonstrates the three pillars from the Quick Start—queries, mutations, and invalidation—in modern (object) syntax.
When to reach for it (rule of thumb)
Use TanStack Query whenever data comes from a server and changes over time (dashboards, admin UIs, analytics, your nutrient dashboards, etc.). It solves loading/error states, caching, pagination, background updates, and consistency out of the box, with minimal code.