A beginner-friendly explanation of the core concepts of TanStack Query, written in a storytelling style. If you've ever wondered "How does TanStack Query actually work behind the scenes?", this guide is for you.
- What is TanStack Query?
- Client State vs Server State
- QueryClient
- QueryClientProvider
- useQuery
- queryKey
- queryFn
- cache
- fresh vs stale
- refetch
- useMutation
- mutationFn
- mutate()
- onSuccess
- useQueryClient
- invalidateQueries()
- Query Flow
- Mutation Flow
- Common Query States
- Summary Table
Imagine you have a backend API.
GET /todos
Whenever your React application needs todos, it has to call this API.
Without TanStack Query, every developer usually writes:
- loading state
- error state
- fetch logic
- refetch logic
- caching logic
again and again.
TanStack Query says:
"Don't worry, I'll manage all of this for you."
Its main job is to manage Server State.
Before learning TanStack Query, you must understand these two terms.
This is the data that only exists inside your React application.
Examples:
- Dark Mode
- Sidebar Open/Close
- Selected Tab
- Counter Value
const [theme, setTheme] = useState("dark");
const [count, setCount] = useState(0);React itself is enough to manage this.
This is the data stored somewhere else.
Usually inside:
- Database
- Backend
- API
Example:
GET /users
↓
Returns
↓
[
{
"id":1,
"name":"Israr"
}
]
This data belongs to the server.
TanStack Query manages this data.
Think of QueryClient as the Brain of TanStack Query.
const queryClient = new QueryClient();The moment you create it, TanStack Query gets a place where it can store everything.
It keeps track of things like:
- Cached Data
- Query Status
- Mutation Status
- Retry Information
- Loading States
- Error States
Think of it like this:
QueryClient
├── Todos Cache
├── Users Cache
├── Posts Cache
├── Loading Status
├── Error Status
└── Mutation Status
Every query and mutation eventually communicates with this client.
Creating a QueryClient isn't enough.
Now the whole application needs access to it.
That's where QueryClientProvider comes in.
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>Think of it like Wi-Fi.
You bought a Wi-Fi router.
Great.
But unless you turn it on and let every device connect to it, nobody can use the internet.
Similarly,
QueryClient = Wi-Fi Router
QueryClientProvider = Sharing the Wi-Fi with the whole app
Without this Provider,
every useQuery() or useMutation() will throw an error because they won't know where the QueryClient is.
This is the most commonly used hook.
const query = useQuery({
queryKey:["todos"],
queryFn:getTodos
});The moment this runs, TanStack Query starts a story.
Component Rendered
↓
Do we already have cached data?
↓
YES
↓
Return Cached Data
OR
↓
NO
↓
Call queryFn()
↓
Receive Data
↓
Save it into Cache
↓
Render UI
The developer doesn't have to manually manage loading, caching, or refetching.
TanStack Query handles everything.
Every query must have an identity.
That's exactly what queryKey is.
queryKey:["todos"]Imagine a library.
Every book has a unique number.
Without that number, the librarian cannot find the correct book.
Similarly,
TanStack Query stores data using keys.
Example:
Cache
↓
todos
↓
[
...
]
Another query:
queryKey:["users"]Now cache becomes:
Cache
├── todos
└── users
Each key points to different cached data.
This is simply the function responsible for fetching data.
queryFn:getTodosExample:
async function getTodos(){
const response = await fetch("/api/todos");
return response.json();
}TanStack Query doesn't know how your backend works.
It simply calls this function whenever data is needed.
One of the biggest reasons developers love TanStack Query.
Imagine this.
Without cache:
Open Page
↓
GET Request
↓
Go Back
↓
Open Again
↓
Another GET Request
↓
Open Again
↓
Another GET Request
Your API keeps getting called again and again.
Now imagine using cache.
Open Page
↓
GET Request
↓
Store Data
↓
Open Again
↓
Read from Cache
↓
Instant UI
Much faster.
Much fewer API calls.
Better user experience.
Every cached data has a condition.
Either it's:
- Fresh
- Stale
Fresh means:
This data is still trusted.
No need to call the API.
TanStack Query happily uses the cache.
Stale means:
This data may be outdated.
Now TanStack Query knows:
Next opportunity I get,
I'll fetch the latest data.
Stale does NOT mean deleted.
It simply means:
"This data should probably be refreshed."
Sometimes you don't want to wait.
You want the newest data immediately.
That's called refetching.
query.refetch();Flow:
Current Data
↓
Call API Again
↓
Receive Latest Data
↓
Replace Cache
↓
Update UI
Queries read data.
Mutations change data.
Whenever you:
- POST
- PUT
- PATCH
- DELETE
you'll usually use:
useMutation()Example:
const mutation = useMutation({
mutationFn:postTodo
});Just like queryFn fetches data,
mutationFn changes data.
Example:
async function postTodo(todo){
await fetch("/todos",{
method:"POST",
body:JSON.stringify(todo)
});
}This function is responsible for sending data to the server.
Nothing happens until you execute the mutation.
mutation.mutate({
title:"Learn Next.js"
});Story:
User Clicks Button
↓
mutate()
↓
mutationFn()
↓
POST Request
↓
Database Updated
Notice something important.
The database changed.
But...
The cache did NOT.
This is where many beginners get confused.
After the mutation finishes successfully,
TanStack Query calls:
onSuccess:()=>{}This is simply a callback.
Think of it as:
POST Finished Successfully
↓
Now execute this code.
This is where developers usually update or refresh cached queries.
Sometimes you need direct access to the QueryClient.
That's exactly what this hook provides.
const queryClient = useQueryClient();Now you can:
- invalidate queries
- manually update cache
- refetch queries
- inspect cached data
Without this hook,
you cannot communicate directly with the QueryClient.
This is probably the most misunderstood concept.
Let's understand it through a story.
Imagine your database currently contains:
Learn React
Learn Node
Your page loads.
useQuery() fetches the data.
Now cache becomes:
Cache
↓
Learn React
Learn Node
Everything looks perfect.
Now the user clicks:
Add Todo
A mutation runs.
POST /todos
Database becomes:
Learn React
Learn Node
Learn Next.js
But...
The cache still contains:
Learn React
Learn Node
Because mutations DO NOT automatically update queries.
TanStack Query has no idea which query should refresh.
Should it refresh:
- todos?
- users?
- dashboard?
- statistics?
It cannot guess.
So you tell it manually.
onSuccess:()=>{
queryClient.invalidateQueries({
queryKey:["todos"]
});
}Now the story becomes:
Mutation Success
↓
Invalidate "todos"
↓
Mark Cache as Stale
↓
Automatically Refetch
↓
Receive Latest Data
↓
Replace Cache
↓
UI Updated
Notice something very important.
invalidateQueries() does NOT update data.
It only says:
This cache is now stale.
Please fetch it again.
That's all.
The actual updated data comes from a new GET request, not from invalidateQueries() itself.
Component Mounted
↓
useQuery()
↓
Check Cache
↓
If Data Exists
↓
Return Cache
↓
Otherwise
↓
Run queryFn()
↓
Receive Data
↓
Save Into Cache
↓
Render UI
User Clicks Button
↓
mutate()
↓
mutationFn()
↓
POST Request
↓
Database Updated
↓
onSuccess()
↓
invalidateQueries()
↓
Cache Marked Stale
↓
GET Request Again
↓
Latest Data Received
↓
Cache Updated
↓
UI Updated
When using useQuery(), you'll frequently encounter these properties.
The actual response returned from your API.
query.dataThe very first request is still loading.
No data yet.
Waiting for server.
A request is currently happening.
This includes background refetches.
Unlike isLoading, this isn't limited to the first request.
Something went wrong.
The request failed.
Everything completed successfully.
The data is available.
| Keyword | Meaning |
|---|---|
| QueryClient | The brain of TanStack Query. Stores caches, queries, mutations, and manages everything internally. |
| QueryClientProvider | Makes the QueryClient available throughout your React application. |
| useQuery | Fetches server data and automatically caches it. |
| useMutation | Performs POST, PUT, PATCH, and DELETE requests. |
| useQueryClient | Gives direct access to the QueryClient instance. |
| queryKey | A unique identifier used to store and retrieve cached data. |
| queryFn | The function responsible for fetching data from the server. |
| mutationFn | The function responsible for changing data on the server. |
| mutate() | Executes the mutation. |
| onSuccess | Runs after a successful mutation. |
| invalidateQueries() | Marks specific cached queries as stale and triggers a refetch. |
| cache | Temporary storage for fetched server data. |
| fresh | Cached data is still trusted. |
| stale | Cached data may be outdated and should be refreshed. |
| refetch | Requests the latest data from the server again. |
| isLoading | Indicates the first request is loading. |
| isFetching | Indicates any request is currently running, including background refetches. |
| isError | Indicates the request failed. |
| isSuccess | Indicates the request completed successfully. |
| data | The data returned from the server. |
TanStack Query is not just a data fetching library.
It is a Server State Management Library.
Instead of worrying about:
- Loading
- Errors
- Refetching
- Caching
- Synchronizing server data
you simply describe what data you need, and TanStack Query takes care of the rest.
Once these core concepts become clear, learning advanced features like pagination, infinite queries, optimistic updates, prefetching, and cache manipulation becomes much easier.
Happy Coding! 🚀