Introduction to TanStack Query
By Flavio Copes
Learn TanStack Query from the server-state mental model through query keys, freshness, errors, mutations, invalidation, optimistic updates, and common mistakes.
TanStack Query is a library for managing server state in React applications.
Server state is data that lives on a server and reaches the browser over the network. TanStack Query fetches it, caches it, shares it between components, refetches it when needed, and handles writes back to the server.
It does not replace every kind of state. Form input, an open menu or the selected tab are still local React state, or a client-state store if several components need it. A simple rule: if the browser owns the data, keep it in React. If the server owns it, hand it to TanStack Query.
If React hooks are new to you, start with the free React course.
Why server state is different
Server state has problems local state does not have.
It goes stale. Another user can change it behind your back. A request can fail. Two components can ask for the same record at the same time. The browser can lose the connection in the middle of a write.
A useEffect fetch handles the first request fine:
function PostList() {
const [posts, setPosts] = useState([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
fetch('/api/posts')
.then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return response.json()
})
.then(setPosts)
.catch(setError)
.finally(() => setIsLoading(false))
}, [])
if (isLoading) return <p>Loading...</p>
if (error) return <p>Could not load posts.</p>
return <PostItems posts={posts} />
}
This works for one component. Then you add a second component that also shows posts, and you have to decide: does it fetch again, or share the data? When is the data old enough to refetch? Should you refetch when the user comes back to the tab? When someone adds a post, how does this list find out?
You can answer all of that with useEffect and some shared state. It gets messy fast. TanStack Query answers those questions for you, with defaults you can change.
Install and add the provider
Install the React package:
npm install @tanstack/react-query
Create one QueryClient for the browser application and pass it through QueryClientProvider:
import {
QueryClient,
QueryClientProvider
} from '@tanstack/react-query'
const queryClient = new QueryClient()
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<PostList />
</QueryClientProvider>
)
}
Do not create new QueryClient() inside a component render. Every render would create a new cache.
Frameworks that render on the server need a client per request on the server and a stable client in the browser. Follow the adapter guide for your framework before adding hydration.
Write a query function
A query function should either return data or throw an error.
Create a small API helper:
async function getPosts() {
const response = await fetch('/api/posts')
if (!response.ok) {
throw new Error(`Could not load posts: HTTP ${response.status}`)
}
return response.json()
}
fetch() does not reject for HTTP 404 or 500. It only rejects for network-level failures. You must check response.ok yourself.
Now use the helper with useQuery():
import { useQuery } from '@tanstack/react-query'
function PostList() {
const {
data: posts,
isPending,
isFetching,
error
} = useQuery({
queryKey: ['posts'],
queryFn: getPosts
})
if (isPending) return <p>Loading...</p>
if (error) return <p>Could not load posts.</p>
return (
<section>
{isFetching && <small>Refreshing...</small>}
<PostItems posts={posts} />
</section>
)
}
isPending means the query has no successful data yet.
isFetching means a request is in progress. It can be true during the first load or during a background refresh while old data stays on screen.
Without that distinction, the whole page would flash back to a loading screen on every background refetch.
Query keys identify cached data
The query key is the address of data in the cache.
The key must include every input used by the query function:
function Post({ postId }) {
const query = useQuery({
queryKey: ['posts', postId],
queryFn: () => getPost(postId)
})
// ...
}
For a filtered list:
useQuery({
queryKey: ['posts', { status, page }],
queryFn: () => getPosts({ status, page })
})
If page changes but the key stays ['posts'], TanStack Query sees the old and new requests as the same data. The cache can show the wrong page.
Use a consistent hierarchy:
['posts']
['posts', 'list', { status, page }]
['posts', 'detail', postId]
Later, when you invalidate ['posts'], every list and detail query under it refetches. Invalidate ['posts', 'detail', 3] and only that post does.
Fresh and stale data
Cached data is stale by default.
Stale does not mean deleted or unusable. It means TanStack Query is allowed to refresh it when a trigger occurs, such as a new component mounting, the window regaining focus, or the network reconnecting.
Set staleTime when you know how long the data can be treated as fresh:
useQuery({
queryKey: ['posts'],
queryFn: getPosts,
staleTime: 60_000
})
For one minute, another component can use the cached posts without a background refetch caused by staleness.
Choose staleTime from the data:
- a user profile might stay fresh for several minutes
- a live order status might stay fresh for seconds
- a static country list might stay fresh for hours
If you find yourself turning off refetchOnWindowFocus and refetchOnMount one by one, stop. What you usually want is a longer staleTime.
Cache lifetime is a different setting
When no component uses a query, it becomes inactive. Inactive queries stay in memory for a while, then garbage collection removes them.
In TanStack Query v5, gcTime controls this lifetime. The client default is five minutes.
useQuery({
queryKey: ['posts'],
queryFn: getPosts,
staleTime: 60_000,
gcTime: 10 * 60_000
})
The two settings are independent. staleTime decides when the data can be refetched. gcTime decides how long the data stays in memory once nobody is using it.
So a query can be stale and still cached. It can also be fresh at the moment the last component using it unmounts.
Retry the right failures
Client-side queries retry failed requests three times by default.
That is useful for temporary network failures. It is wasteful for a definite 404 or 401.
Give your error a status:
async function getPost(postId) {
const response = await fetch(`/api/posts/${postId}`)
if (!response.ok) {
const error = new Error(`HTTP ${response.status}`)
error.status = response.status
throw error
}
return response.json()
}
Then choose which errors can retry:
useQuery({
queryKey: ['posts', 'detail', postId],
queryFn: () => getPost(postId),
retry: (failureCount, error) => {
if ([401, 403, 404].includes(error.status)) return false
return failureCount < 3
}
})
Retries on the server default to zero because server rendering should fail quickly.
Retries only work well if your server returns the right status codes. And a retried request hits the server again, so the endpoint must be safe to call twice.
Dependent queries
Sometimes one request needs data from another.
Use enabled to wait for the dependency:
const userQuery = useQuery({
queryKey: ['user'],
queryFn: getCurrentUser
})
const projectsQuery = useQuery({
queryKey: ['projects', userQuery.data?.id],
queryFn: () => getProjects(userQuery.data.id),
enabled: Boolean(userQuery.data?.id)
})
This is easy to read, but it is a waterfall: the projects request cannot start until the user request finishes. Two round trips instead of one.
If you control the server, an endpoint that returns the user together with their projects is faster. Use enabled when you have to, not as the default way to combine data.
Mutations change server state
Queries read data from the server. When you need to change something on the server, you use a mutation.
Create an API function:
async function addPost(post) {
const response = await fetch('/api/posts', {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify(post)
})
if (!response.ok) {
throw new Error(`Could not add post: HTTP ${response.status}`)
}
return response.json()
}
Use it with useMutation():
import {
useMutation,
useQueryClient
} from '@tanstack/react-query'
function AddPostButton() {
const queryClient = useQueryClient()
const addPostMutation = useMutation({
mutationFn: addPost,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['posts', 'list']
})
}
})
return (
<button
disabled={addPostMutation.isPending}
onClick={() => {
addPostMutation.mutate({
title: 'A new post'
})
}}
>
{addPostMutation.isPending ? 'Saving...' : 'Add post'}
</button>
)
}
invalidateQueries() marks every query matching the key as stale. The ones currently on screen refetch in the background.
Nothing is deleted from the cache, and TanStack Query has no idea what changed on the server. You tell it which keys are affected.
Update the cache from the response
If the server returns the complete saved post, you can update its detail cache immediately:
const savePostMutation = useMutation({
mutationFn: savePost,
onSuccess: savedPost => {
queryClient.setQueryData(
['posts', 'detail', savedPost.id],
savedPost
)
queryClient.invalidateQueries({
queryKey: ['posts', 'list']
})
}
})
The detail view gets the exact server response. Lists refetch because sorting, filtering, or derived fields can be harder to update correctly by hand.
My advice is to start with invalidation everywhere. Add setQueryData() only where the extra refetch is visibly slow.
Optimistic updates
An optimistic update changes the interface before the server confirms the write.
It can make a frequent action feel instant. It also needs rollback logic.
Here is a favorite button:
const favoriteMutation = useMutation({
mutationFn: setFavorite,
onMutate: async ({ postId, favorite }) => {
const key = ['posts', 'detail', postId]
await queryClient.cancelQueries({ queryKey: key })
const previous = queryClient.getQueryData(key)
queryClient.setQueryData(key, old => ({
...old,
favorite
}))
return { key, previous }
},
onError: (_error, _variables, context) => {
queryClient.setQueryData(context.key, context.previous)
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
queryKey: ['posts', 'detail', variables.postId]
})
}
})
We cancel an in-flight refetch so it cannot overwrite the optimistic value. We save the previous cache value, update immediately, restore on error, and refetch when finished.
Do not use optimistic updates for every write. Payment, inventory, permission, and destructive operations often need server confirmation before the UI claims success.
Cancellation and query functions
TanStack Query gives each query function an AbortSignal:
async function getPosts({ signal }) {
const response = await fetch('/api/posts', { signal })
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return response.json()
}
Use it in the query:
useQuery({
queryKey: ['posts'],
queryFn: getPosts
})
Now if the component unmounts or the key changes while the request is still running, TanStack Query aborts it. The browser stops waiting on data nobody will display.
Prefetch before navigation
If you know the user is likely to open a post, prefetch its detail:
function PostLink({ post }) {
const queryClient = useQueryClient()
function prefetch() {
queryClient.prefetchQuery({
queryKey: ['posts', 'detail', post.id],
queryFn: () => getPost(post.id),
staleTime: 60_000
})
}
return (
<a href={`/posts/${post.id}`} onMouseEnter={prefetch}>
{post.title}
</a>
)
}
When the detail component mounts, useful data may already be in the cache.
Prefetch likely next steps, not every link on the page. Unused prefetches still consume network and server resources.
Common mistakes
The most common one is ignoring response.ok. TanStack Query only knows a request failed when the query function throws. If you return response.json() on a 500, the error body goes into the cache as if it were data.
The second is leaving a parameter out of the key. If the query function uses page, page goes in the key. Otherwise two different pages share one cache entry.
Treating stale as missing is another one. Stale data is still good data. Show it, and use isFetching to signal that a refresh is happening. Only isPending means you have nothing to show.
Watch out for invalidateQueries() with a key that is too broad. Invalidating ['posts'] after editing one post refetches every list and every detail page. That’s fine for a small app and expensive as it grows. With a clear key hierarchy you can invalidate only the queries an edit affects.
Do not copy query data into useState. Now you have two copies, and the one in useState never updates when the cache does. Derive what you need from data during render.
For the same reason, do not put server data in a client-state store. Storing the posts list in Zustand means giving up freshness, deduplication, invalidation, and retries. Zustand is for state the browser owns.
One more, about security. A query key with userId in it does not protect anything. It only organizes the cache. The server must still check who is asking, on every request.
TanStack Query versus SWR
SWR does the same job with a smaller API. If your app mostly reads data and rarely writes, SWR is enough and there is less to learn.
TanStack Query has more built in: mutations, invalidation, optimistic updates, offline support, devtools. You pay for that with a bigger API.
Neither one will save you from a messy server API or badly designed keys. Get those right first.
How I would use TanStack Query
I would add it when an application has several server-backed screens, repeated data, writes that affect reads, or background freshness requirements.
I would first create query-key conventions and small API functions that throw useful errors. Then I would add normal queries and invalidation. I would delay optimistic updates until a specific interaction felt slow.
I would not add TanStack Query to a static page with one request that runs once. A framework loader or a small fetch can be enough.
Making one request is easy. TanStack Query is for the harder problem of keeping five components that show the same data in agreement while users edit it.
Want me to talk about your product? You can sponsor this site.