If you've ever written a useEffect to fetch data, you've probably also written a useState for the data, one for loading, one for error, some logic to avoid stale closures, and a cleanup function to cancel the request. And then done it again in the next component.
TanStack Query replaces all of that.
The Problem It Solves
Before TanStack Query, fetching data in React looked like this:
function UserProfile({ userId }) {
const [user, setUser] = useState(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
setIsLoading(true)
fetch(`/api/users/${userId}`)
.then((r) => r.json())
.then(setUser)
.catch(setError)
.finally(() => setIsLoading(false))
}, [userId])
if (isLoading) return <Spinner />
if (error) return <ErrorMessage error={error} />
return <div>{user.name}</div>
}
This is a lot of boilerplate, and it doesn't common usecases like: background refetching, deduplication, or caching.
The TanStack Query Way
function UserProfile({ userId }) {
const {
data: user,
isLoading,
error,
} = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
})
if (isLoading) return <Spinner />
if (error) return <ErrorMessage error={error} />
return <div>{user.name}</div>
}
Same result, a fraction of the code. But the real gains are what happens behind the scenes.
What TanStack Query Actually Does
Caching
Every query result is stored in a cache keyed by the queryKey. If another component mounts and requests the same key, it gets the cached data instantly from the cache, no second network request.
Background Refetching
When you revisit a page or refocus the browser tab, TanStack Query automatically refetches stale data in the background. The user sees the cached (potentially stale) data immediately, then gets a silent update when fresh data arrives.
Deduplication
If ten components mount simultaneously and all request the same query key, TanStack Query fires exactly one network request, then shares the result across all of them.
Automatic Retries
Failed requests are retried automatically (three times by default) with exponential backoff.
Loading and Error States
isLoading, isFetching, isError, isSuccess — all computed for you based on the query lifecycle.
Mutations
For writing data (POST, PUT, DELETE), TanStack Query offers the hook useMutation:
const { mutate, isPending } = useMutation({
mutationFn: (newUser) =>
fetch('/api/users', {
method: 'POST',
body: JSON.stringify(newUser),
}).then((r) => r.json()),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
Calling queryClient.invalidateQueries after a successful mutation tells TanStack Query to refetch any cached data that matches so your UI stays fresh automatically.
Query Keys
Query keys are how TanStack Query identifies and manages cached data. They're arrays, and they work like a hierarchy:
['users'] // all users
[('users', { page: 1 })] // users on page 1
[('user', userId)] // a single user
[('user', userId, 'posts')] // posts for a specific user
You can invalidate an entire subtree at once: invalidateQueries({ queryKey: ['user', userId] }) would refetch both the user and their posts.
When to Use TanStack Query
Use TanStack Query when your component needs data from an external source: an API, a database, anything asynchronous.
It's the right tool for almost any server state problem in a React app.
For state that lives entirely in the browser (UI state, form state, selected items), you don't need it, reach for useState or a lightweight store instead.
