You already know how to fetch data in React. It looks like this:
import { useEffect, useState } from 'react'
function ProjectList() {
const [projects, setProjects] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
fetch('/api/projects')
.then(res => res.json())
.then(data => {
setProjects(data)
setLoading(false)
})
.catch(err => {
setError(err)
setLoading(false)
})
}, [])
if (loading) return <p>Loading...</p>
if (error) return <p>Something went wrong.</p>
return <ul>{projects.map(p => <li key={p.id}>{p.name}</li>)}</ul>
}
This works, but it has problems you start noticing once your app grows:
/api/projects, they each fire a separate requestnpm install @tanstack/react-query
Wrap your app in a QueryClientProvider. In a Next.js App Router project this goes in a Client Component:
// app/providers.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient())
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
// app/layout.tsx
import { Providers } from './providers'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
<aside> 💡
The useState(() => new QueryClient()) pattern ensures each user session gets its own cache instance. If you write new QueryClient() outside of state, the same instance is shared across server renders, which causes data from one user to bleed into another user's session.
</aside>
useQuery - Reading DatauseQuery replaces the useEffect + useState fetching pattern. You give it a query key and a fetch function, and it gives you back the loading state, data, and error - plus automatic caching.
import { useQuery } from '@tanstack/react-query'
function ProjectList() {
const { data, isPending, isError } = useQuery({
queryKey: ['projects'],
queryFn: () => fetch('/api/projects').then(res => res.json()),
})
if (isPending) return <p>Loading...</p>
if (isError) return <p>Something went wrong.</p>
return <ul>{data.map(p => <li key={p.id}>{p.name}</li>)}</ul>
}
This replaces the manual pattern above. The properties you'll use most often:
| Property | What it is |
|---|---|
data |
The resolved value from your queryFn, or undefined while loading |
isPending |
true when there's no cached data and the first fetch hasn't finished |
isError |
true when the last fetch threw an error |
error |
The thrown error object |
isFetching |
true whenever a fetch is in flight (including background refetches) |
refetch |
A function to manually trigger a fresh fetch |
Pass a variable into the query key and the fetch function:
function ProjectDetail({ id }: { id: string }) {
const { data: project, isPending } = useQuery({
queryKey: ['projects', id],
queryFn: () => fetch(`/api/projects/${id}`).then(res => res.json()),
})
if (isPending) return <p>Loading project...</p>
return <h1>{project.name}</h1>
}
The query key ['projects', id] means each project ID gets its own cache entry. Navigating between /projects/1 and /projects/2 fetches each one once and caches them separately.
useMutation - Writing DatauseMutation handles requests that change data on the server: POST, PUT, PATCH, DELETE. Unlike useQuery, mutations don't run automatically - you call the mutate function when something happens.
import { useMutation, useQueryClient } from '@tanstack/react-query'
function AddProjectForm() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: (newProject: { name: string }) =>
fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newProject),
}).then(res => res.json()),
onSuccess: () => {
// Tell TanStack Query the projects list is now stale
queryClient.invalidateQueries({ queryKey: ['projects'] })
},
})
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const form = e.currentTarget
const name = (form.elements.namedItem('name') as HTMLInputElement).value
mutation.mutate({ name })
}
return (
<form onSubmit={handleSubmit}>
<input name="name" placeholder="Project name" required />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Adding...' : 'Add project'}
</button>
{mutation.isError && <p>Failed to add project. Try again.</p>}
</form>
)
}
What useMutation returns that you'll use most often:
| Property | What it is |
|---|---|
mutate(variables) |
Triggers the mutation; doesn't return a promise |
mutateAsync(variables) |
Same, but returns a promise (useful when you need to await it) |
isPending |
true while the mutation is in flight |
isSuccess |
true after a successful mutation |
isError |
true if the mutation threw |
error |
The thrown error |
reset() |
Clears the error/success state |
A query key is the unique identifier for a piece of cached data. TanStack Query uses it to:
invalidateQueriesQuery keys are always arrays. The first element is usually a string describing the resource, and additional elements narrow it down:
['projects'] // all projects
['projects', projectId] // one project by ID
['projects', projectId, 'comments'] // comments for a project
['user', userId, 'settings'] // settings for a specific user
If two components use the same query key, they share the same cache entry - the request is only made once, and both components update together when the data changes.
If they use different query keys, they get separate cache entries and separate requests. Use this when the data is genuinely different, not when you just want independent loading states.
// These two components share one cache entry - one request
function ProjectSidebar() {
const { data } = useQuery({ queryKey: ['projects'], queryFn: fetchProjects })
// ...
}
function ProjectCount() {
const { data } = useQuery({ queryKey: ['projects'], queryFn: fetchProjects })
// ...
}
// These use separate cache entries - two requests
function FeaturedProjects() {
const { data } = useQuery({ queryKey: ['projects', 'featured'], queryFn: fetchFeatured })
}
function AllProjects() {
const { data } = useQuery({ queryKey: ['projects', 'all'], queryFn: fetchAll })
}
<aside> ⚠️
Query keys must be serialisable (strings, numbers, booleans, arrays, plain objects). Don't include class instances, functions, or Dates directly in a key. For dates, convert to an ISO string first.
</aside>
TanStack Query tracks whether each cache entry is fresh or stale. Fresh data is served from cache without a network request. Stale data is served from cache immediately (so the UI doesn't flash) but a background refetch starts right away to update it.
By default, data becomes stale as soon as it's fetched (staleTime: 0). That means every time a component mounts, TanStack Query shows the cached data and quietly refetches in the background.
You can tune this:
useQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
staleTime: 1000 * 60 * 5, // fresh for 5 minutes - no background refetch during this window
})
When does TanStack Query refetch?
refetch() manuallyinvalidateQueries() after a mutationThis model means users almost never see a loading spinner for data they've recently viewed. They see the cached version immediately and the UI quietly updates if anything changed.
// Global defaults - set these on the QueryClient
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute freshness by default
gcTime: 1000 * 60 * 10, // keep unused data in memory for 10 minutes
},
},
})
<aside> 💡
gcTime (previously cacheTime) controls how long unused data stays in memory before it's garbage collected. An entry becomes unused when no components are subscribed to it. Increase this if users often navigate back to pages they've already visited.
</aside>