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 manages server state in React applications.
It fetches data, caches it, shares it between components, refetches it when needed, and coordinates writes back to the server.
It does not replace every kind of state. Form input, an open menu, and the selected tab still belong in local React state.
The useful split is:
state owned by the browser -> useState or a client-state store
state owned by the server -> TanStack Query
If React hooks are new to you, start with the free React course.
Why server state is different
Server state has properties local state does not have.
It can become stale. Another user can change it. A request can fail. Two components can ask for the same record. The browser can lose connectivity while a write is in progress.
A small useEffect fetch handles the first request:
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} />
}
Then the real questions begin.
What happens if the component unmounts? Should another component make the same request? When does the data become stale? Should it refetch when the user returns to the tab? How does a new post update this list?
TanStack Query gives those decisions one place to live.
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.
Keeping those states separate prevents the whole page from flashing back to a loading screen during every 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]
This also makes broad or narrow invalidation predictable.
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
Do not disable refetches one by one before understanding freshness. A useful staleTime often expresses the requirement more clearly.
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
})
staleTime answers: “When may this data be refetched?”
gcTime answers: “How long should unused data remain in memory?”
A query can be stale and still cached. It can also be fresh when the last component 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.
TanStack Query coordinates retries. Your server still needs correct status codes and idempotent behavior.
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 clear, but it creates a request waterfall: projects cannot start until the user finishes.
If the server can return both values together, one endpoint can be faster. Do not turn every data dependency into a chain of browser requests.
Mutations change server state
Queries read. Mutations create, update, or delete.
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>
)
}
Invalidation marks matching queries stale. Active matching queries refetch in the background.
It does not delete the cache or magically know which server records changed. Your mutation callback chooses the affected keys.
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 prefer invalidation first. Add manual cache updates when they improve a measured interaction.
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 the request can be aborted when the query becomes irrelevant. This avoids doing work for data the interface no longer needs.
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
Ignoring response.ok
TanStack Query only sees an error when the query function throws. A parsed 500 response can otherwise enter the cache as successful data.
Leaving parameters out of the key
If the query function uses a value, put that value in the key.
Treating stale as missing
Stale data can remain visible while a background request refreshes it. Use isPending and isFetching for different interface states.
Invalidating everything
invalidateQueries() without a useful key can refetch unrelated data. Design keys before the application grows.
Copying query data into useState
This creates a second source of truth that drifts from the cache. Derive display values from query data instead.
Storing server data in a client-state store
You lose the freshness, deduplication, invalidation, and retry model. Use a client store for client-owned state.
Expecting the cache to enforce authorization
The server must authorize every request. A query key containing userId is organization, not security.
TanStack Query versus SWR
Both libraries manage server data in React.
SWR has a small API and works well for straightforward reads. TanStack Query offers a broader mutation, invalidation, optimistic-update, offline, and devtools story.
Choose the smaller mental model that fits your application. Switching libraries will not fix an unclear server API or bad cache keys.
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.
The library is most useful when the hard part is no longer “make this request”. The hard part is keeping many views of remote data coherent over time.
Want me to talk about your product? You can sponsor this site.