React: TanStack Query(React Query)
Last updated: 2026-08-26
In the last classe, Tom used custom hooks and axios to wrap HTTP requests, but new problems arose: both the dashboard and the sidebar display the number of users, and each component sends its own requisição (wasting bandwidth and servidor resources); when a user changes their username on Page A, the data on Page B remains outdated; and after submitting an edit form, the user must manually trigger a data refresh. He realized: A “servidor-side state management” solution is needed to treat API data as a special type of state—one that is cached, has an expiration time, and can be automatically synchronized.
1. What You'll Learn
- useQuery manages data retrieval (fetching, caching, and automatic re-fetching)
- useMutation manages data writes (insertions, deletions, updates, and stale queries)
- staleTime / cacheTime control the caching policy
- Optimistic updates enable real-time UI responsiveness
- Distinguishing Between Server-Side State and Client-Side State
2. Conceptual Diagrams
flowchart TD
A[useQuery Call] --> B{Cache Hit?}
B -->|No matches found| C[Initiate API Request]
B -->|Hit but Expired| C
B -->|On Target and Fresh| D[Return cached data directly]
C --> E[Cached Data]
E --> F[Rendering Component]
F --> G{staleTime Has it expired??}
G -->|Not yet due| H[Data labeled as"Fresh"]
G -->|Expired| I[Data labeled as"Expired"]
I --> J{Bring the window back to the foreground?}
J -->|is | C
I --> K{refetchInterval?}
K -->|is | C
I --> L{There's something new useMutation<br/>invalidate?}
L -->|is | C
style A fill:#e1f5fe,stroke:#0288d1
style E fill:#fff3e0,stroke:#f57c00
style H fill:#e8f5e9,stroke:#388e3c
style I fill:#ffcdd2,stroke:#d32f2f
The core mechanism of TanStack Query: Read from the cache first → Mark as fresh or expired → Automatically trigger a new request when expired.
3. A Real-Life Scenario
Tom's dashboard needs to display the total number of users, the total number of orders, and a list of recent orders. This data comes from three different APIs and must be kept up to date in real time—when a user modifies data on another page and returns to the dashboard, they should see the latest results. Additionally, after the "Add Product" form is submitted, the product list should refresh automatically, rather than requiring a manual page refresh.
(1) Problem: Manually managing the cache is too difficult
Without TanStack Query, Tom had to handle it himself:
// Manually Manage the Cache — You have to write similar logic for each component.
function Dashboard() {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/stats').then(res => res.json())
.then(d => { setData(d); setLoading(false) })
.catch(e => { setLoading(false) })
}, [])
// Question 1:Switch to another page and then come back → Resubmit Request(Waste!)
// Question 2:If another component also needs the same data → Duplicate Request
// Question 3:The data does not refresh automatically,Users may be seeing data from a few minutes ago
}
TanStack Query solves all of the above issues with a single useQuery hook.
| Feature | Manual fetch + useEffect | TanStack Query |
|---|---|---|
| Cache Management | No cache; data is lost upon removal | Automatic caching; managed by queryKey |
| Duplicate Request | Multiple components using the same data → Duplicate requests | Automatically remove duplicates; request only once |
| Auto-refresh | None | Auto-retrieve on window focus/reconnection |
| Backend Update | None | staleTime controls backend refresh |
| Loading/Error Status | Manually managed loading/error | Automatically provided data/loading/error |
| Optimistic Update | Manual Implementation | onMutate + onError Rollback |
npm install @tanstack/react-query
(2) Initialize the Provider
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
// Create QueryClient(Usually at app Entrance)
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000, // Default 30 Data within seconds is considered"Fresh"
cacheTime: 5 * 60 * 1000, // Cache Retention 5 minutes
retry: 3, // Failure retry 3 times
refetchOnWindowFocus: true, // Refresh when the window returns to the foreground
}
}
})
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
)
}
4. useQuery: Retrieving Data
▶ Example 1: Retrieving Dashboard Data
Output:
Kanban board: drag tasks between columns (Todo/Doing/Done)
import { useQuery } from '@tanstack/react-query'
// Encapsulated API Function
async function fetchDashboardStats() {
const response = await fetch('/api/dashboard/stats')
if (!response.ok) throw new Error('Failed to retrieve statistics')
return response.json()
}
function Dashboard() {
// useQuery One line of code replaced useState + useEffect + Manual Caching
const {
data: stats, // Response Data
isLoading, // Loading for the first time(When there is no cache)
isFetching, // Is a request being made?(Including background retries)
error, // Error Object
refetch // Manually Trigger Retrieval
} = useQuery({
queryKey: ['dashboardStats'], // Unique Identifier,Used for caching matches
queryFn: fetchDashboardStats, // Data Retrieval Functions
staleTime: 30 * 1000, // 30 Do not resend the request within seconds
retry: 3, // Failure retry 3 times
})
if (isLoading) return <DashboardSkeleton />
if (error) return <ErrorPanel message={error.message} onRetry={refetch} />
return (
<div className="dashboard">
<StatCard title="Total Number of Users" value={stats.users} />
<StatCard title="Total Number of Orders" value={stats.orders} />
<StatCard title="Total Revenue" value={`$${stats.revenue}`} />
</div>
)
}
// The sidebar also displays the number of users — Using the same queryKey,No duplicate requests!
function Sidebar() {
const { data: stats } = useQuery({
queryKey: ['dashboardStats'],
queryFn: fetchDashboardStats,
staleTime: 30 * 1000,
})
return (
<aside>
<p>Users Online:{stats?.onlineUsers ?? '...'}</p>
</aside>
)
}
Output:
Displays "{data.name}"
Key Mechanism: Identical queryKey instances share the same cache. The Dashboard and Sidebar use the same ['dashboardStats'], and TanStack Query automatically deduplicates requests—each component triggers only one API request, and both components are updated simultaneously once the data is returned.
▶ Example 2: Queries with Parameters
Output:
Displays: "if (error) return". useEffect manages side effects. Async data fetching/loading states
function ProductDetail({ productId }) {
const { data, isLoading, error } = useQuery({
queryKey: ['product', productId], // queryKey Includes parameters
queryFn: async () => {
const res = await fetch(`/api/products/${productId}`)
if (!res.ok) throw new Error('The product does not exist.')
return res.json()
},
enabled: !!productId, // productId Do not send a request if it is empty
staleTime: 60 * 1000,
})
if (isLoading) return <p>Loading product details...</p>
if (error) return <p>Error:{error.message}</p>
return (
<div>
<h2>{data.name}</h2>
<p className="price">${data.price}</p>
<p>{data.description}</p>
</div>
)
}
Output:
useQuery with dynamic params: select user from list → fetch /api/users/{id} → display user details. Auto-refetches when param changes
The significance of parameters included in queryKey: TanStack Query uses queryKey as a unique identifier for the cache. ['product', 1] and ['product', 2] are two separate caches that do not interfere with each other. When productId changes from 1 to 2, the system prioritizes reading ['product', 2] from the cache; if it is cached and not expired, it renders directly; otherwise, it sends a request.
▶ Example: Key fields returned by useQuery
Output:
Subheading: "{data.name}". Displays: "Loading product details...". Async data fetching/loading states
| Field | Meaning | Use Case |
|---|---|---|
data |
Data from the last successful response | Render UI |
isLoading |
First load with no cached data | Display the skeleton screen on first load |
isFetching |
Any ongoing requests (including background retries) | Display background refresh indicator |
error |
Error object for failed request | Display error message |
refetch |
Function to manually trigger a re-request | "Refresh" button |
isStale |
Is the data out of date? | Conditionally display update prompts |
5. useMutation: Writing Data
Use useQuery for reads and useMutation for writes. This is the golden rule of TanStack Query.
▶ Example 3: Add a product and refresh the list
Output:
Subheading: "Product Management". Button: {addProductMutation.isLoading ? 'Submitting......' : 'Add Item'}. Input: Product Name, Price. Form with submit handler. List items: {p.name} — ${p.price}. async data fetching
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
// Product List Search
function useProducts() {
return useQuery({
queryKey: ['products'],
queryFn: async () => {
const res = await fetch('/api/products')
return res.json()
}
})
}
function ProductManager() {
const queryClient = useQueryClient()
// Add a product mutation
const addProductMutation = useMutation({
mutationFn: async (newProduct) => {
const res = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newProduct)
})
if (!res.ok) throw new Error('Failed to add')
return res.json()
},
// Steps to Take After Success:Invalidate the product list cache,Trigger a re-request
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] })
// Optional:Display a success message at the same time
alert('Item added successfully!')
},
// Error Handling
onError: (error) => {
alert(`Failed to add:${error.message}`)
}
})
// Data
const { data: products, isLoading } = useProducts()
// Form Submission
function handleSubmit(event) {
event.preventDefault()
const formData = new FormData(event.target)
const newProduct = {
name: formData.get('name'),
price: Number(formData.get('price'))
}
addProductMutation.mutate(newProduct)
event.target.reset()
}
return (
<div>
<h2>Product Management</h2>
<form onSubmit={handleSubmit}>
<input name="name" placeholder="Product Name" required />
<input name="price" type="number" placeholder="Price" required />
<button type="submit" disabled={addProductMutation.isLoading}>
{addProductMutation.isLoading ? 'Submitting......' : 'Add Item'}
</button>
</form>
{addProductMutation.isError && (
<p className="error">Submission Failed:{addProductMutation.error.message}</p>
)}
<hr />
<h3>Product List</h3>
{isLoading && <p>Loading......</p>}
{products && (
<ul>
{products.map(p => (
<li key={p.id}>{p.name} — ${p.price}</li>
))}
</ul>
)}
</div>
)
}
Output:
useQuery({ queryKey: ["users"], queryFn: fetchUsers }) → { data, isLoading, error }. Automatic caching + background refetch.
Core Process: useMutation.mutate() Triggers a POST request → Server processes the request → onSuccess Executes invalidateQueries in the callback → TanStack Query automatically re-retrieves ['products'] data → The list UI automatically updates.
Why not use setData manually? You can call queryClient.setQueryData to manually update the cache, but a better approach is to have TanStack Query re-request the data from the server via invalidateQueries—this ensures that the data always comes from the server and prevents inconsistencies between the front-end cache and the server-side data.
6. Optimistic Updates
When network latency is high, users have to wait for a server response after submitting a request before they can see changes in the UI, resulting in a poor user experience. Optimistic updating updates the UI immediately before the request is sent, and rolls back to the previous state if the request fails.
▶ Example 4: Toggling the task completion status
Output:
Subheading: "Product Management". Displays: "Product Management". Button: {addProductMutation.isLoading ? 'Submitting......' : 'Add Item'}. Input: Product Name, Price. Form with submit handling. List: {p.name} — ${p.price}. Async data fetching/loading states
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
// Get the To-Do List
function useTodos() {
return useQuery({
queryKey: ['todos'],
queryFn: async () => {
const res = await fetch('/api/todos')
return res.json()
}
})
}
function TodoList() {
const queryClient = useQueryClient()
const { data: todos } = useTodos()
// Switch to "Completed" status — Using Optimistic Updates
const toggleMutation = useMutation({
// Actual API Request
mutationFn: async ({ id, done }) => {
const res = await fetch(`/api/todos/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ done })
})
if (!res.ok) throw new Error('Update Failed')
return res.json()
},
// ===== Optimistic Update Begins =====
onMutate: async ({ id, done }) => {
// 1. Cancel all ongoing todos Search(Avoid Overwriting Optimistic Updates)
await queryClient.cancelQueries({ queryKey: ['todos'] })
// 2. Save the current cache data,Used for rollback in case of failure
const previousTodos = queryClient.getQueryData(['todos'])
// 3. Refresh the cache immediately
queryClient.setQueryData(['todos'], (old) =>
old.map(todo =>
todo.id === id ? { ...todo, done } : todo
)
)
// 4. Restore old data for rollback
return { previousTodos }
},
// ===== End of Optimistic Update =====
// Rollback on Failure
onError: (err, variables, context) => {
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos)
}
},
// Whether we succeed or fail,Finally, resend the request to ensure synchronization with the server.
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
}
})
return (
<ul>
{todos?.map(todo => (
<li key={todo.id} style={{ opacity: toggleMutation.isLoading ? 0.7 : 1 }}>
<input
type="checkbox"
checked={todo.done}
onChange={() => toggleMutation.mutate({ id: todo.id, done: !todo.done })}
/>
<span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
{todo.title}
</span>
</li>
))}
</ul>
)
}
Output:
useQuery({ queryKey: ["users"], queryFn: fetchUsers }) → { data, isLoading, error }. Automatic caching + background refetch.
The Three-Step Guide to Optimism:
onMutate: Update the cache immediately before sending the request so users can see the changes right away; save the old data in case a rollback is needed.onError: Restore the cache (rollback) using saved historical data when a request failsonSettled: Regardless of success or failure, ultimately request the data again from the server to ensure absolute consistency.
7. Server-Side State vs. Client-Side State
To understand the concept behind TanStack Query, it is necessary to distinguish between two states:
| Dimension | Server State | Client State |
|---|---|---|
| Source | Backend API / Database | Frontend (Local, User Actions) |
| Ownership | The server owns the data | The front end owns the data |
| Persistence | Stored in a database | Stored in memory or localStorage |
| Synchronization Requirements | Must stay synchronized with the server | Does not need to stay synchronized with the server |
| Update method | Write via API + Re-fetch | Directly use setState |
| Management Tools | TanStack Query | State / Redux Toolkit |
| Example | User list, product data, order information | Pop-up toggle, form input values, theme color |
Core Principles:
- Data returned by the API (user list, product information, order status) → Managed using TanStack Query
- Front-end local state (opening/closing modals, input field values, theme settings) → Manage using Zustand / Context / useState
▶ Example: TanStack Query DevTools
TanStack Query provides a dedicated DevTools component that allows you to view the cache status, expiration time, and last update time for all queries during development.
npm install @tanstack/react-query-devtools
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
{/* Display only in the development environment DevTools */}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
Output:
Input type: checkbox. Async data fetching/loading states
DevTools Features:
- View all queryKeys and their corresponding cached data
- View the stale, active, and inactive statuses of each cache entry
- Manually trigger refetch, invalidate, and remove operations
- Monitor the network latency and response size of requests
❓ FAQ
useEffect + fetch?queryKey, duplicates are automatically filtered out to prevent redundant requests; (2) Automatic re-fetching—data is automatically refreshed when the window is brought back to the foreground, the network reconnects, or during periodic polling; (3) Lifecycle management—automatic handling of loading, error, and data states; automatic retries upon failure; and no need to manually implement an AbortController to cancel requests. A single useQuery replaces 20 lines of useEffect + fetch + useState.staleTime and cacheTime? What happens when staleTime = 0?staleTime controls the "freshness" of the data—within this timeframe, the data is considered fresh and will not trigger an automatic re-fetch. cacheTime controls the cache retention time—how long the cache remains after the component is unmounted before being garbage collected. staleTime = 0 means that data is marked as expired as soon as it is returned, triggering a background re-fetch every time it is used (though cached data is returned first and then updated). It is recommended to set staleTime = 30s to avoid request jitter.onSuccess method of useMutation, how should I choose between invalidateQueries and setQueryData?invalidateQueries—invalidate the cache to force TanStack Query to re-fetch data from the server, ensuring data consistency. setQueryData is suitable for scenarios with very few changes and a known return format (such as generating a client-side unique ID). If you need to display the results of user actions immediately (without waiting for a server response), use optimistic updates (onMutate + rollback) instead of setQueryData.queryKey, only one request will be sent.📖 Summary
- useQuery manages data retrieval: automatic caching, deduplicating requests, background re-fetching, and retries on failure
- useMutation manages data writes: works with invalidateQueries to automatically trigger data refreshes
staleTimecontrols data freshness, andcacheTimecontrols the cache retention time- Optimized updates: Use
onMutateto update the UI first,onErrorto roll back, andonSettledfor final synchronization, thereby improving the user experience - Use TanStack Query for server-side state (API data) and Zustand/Context for client-side state (UI state)
📝 Exercises
- Create a user list component using
useQuery: Usehttps://jsonplaceholder.typicode.com/usersas the API, implement cache sharing (both components use the samequeryKeyto ensure the request is sent only once), and add a manual refresh button. - Use
useMutationto implement the "Add Product" feature: When the form is submitted, it triggers a POST request; if successful, the product list automatically refreshes; if unsuccessful, an error message is displayed. - Implement an optimistic update to "toggle the completion status of a to-do item": Clicking the checkbox immediately toggles the status; if the API request fails, the change is rolled back. After writing the code, test it by disconnecting from the network and clicking the checkbox to observe the rollback behavior.