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



2. Conceptual Diagrams

100%
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:

JSX
// 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
}
▶ Try it Yourself

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
BASH
npm install @tanstack/react-query

(2) Initialize the Provider

JSX
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>
  )
}
▶ Try it Yourself

4. useQuery: Retrieving Data

▶ Example 1: Retrieving Dashboard Data

Output:

TEXT 📖 Display only
Kanban board: drag tasks between columns (Todo/Doing/Done)
JSX
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:

TEXT 📖 Display only
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:

TEXT 📖 Display only
Displays: "if (error) return". useEffect manages side effects. Async data fetching/loading states
JSX
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>
  )
}
▶ Try it Yourself

Output:

TEXT 📖 Display only
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:

TEXT 📖 Display only
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:

TEXT 📖 Display only
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
JSX
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:

TEXT 📖 Display only
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:

TEXT 📖 Display only
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
JSX
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:

TEXT 📖 Display only
useQuery({ queryKey: ["users"], queryFn: fetchUsers }) → { data, isLoading, error }. Automatic caching + background refetch.

The Three-Step Guide to Optimism:

  1. 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.
  2. onError: Restore the cache (rollback) using saved historical data when a request fails
  3. onSettled: 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:

▶ 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.

BASH
npm install @tanstack/react-query-devtools
JSX
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>
  )
}
▶ Try it Yourself

Output:

TEXT 📖 Display only
Input type: checkbox. Async data fetching/loading states

DevTools Features:

  1. View all queryKeys and their corresponding cached data
  2. View the stale, active, and inactive statuses of each cache entry
  3. Manually trigger refetch, invalidate, and remove operations
  4. Monitor the network latency and response size of requests

❓ FAQ

Q What are the key advantages of TanStack Query compared to using useEffect + fetch?
A Three key advantages: (1) Shared caching—when multiple components use the same 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.
Q What is the difference between staleTime and cacheTime? What happens when staleTime = 0?
A 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.
Q In the onSuccess method of useMutation, how should I choose between invalidateQueries and setQueryData?
A The preferred option is 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.
Q When multiple components use the same queryKey, how does TanStack Query determine when to trigger a new request?
A The rules are: (1) If the cache hit and the data is not expired (within the staleTime), return the cached data directly without sending a request; (2) If the cache hit occurs but the cache has expired, the cached result is returned immediately, and a new request is initiated in the background; (3) If there is no cache, a request is initiated. Regardless of how many components subscribe to the same queryKey, only one request will be sent.
Q Can TanStack Query and Zustand be used together?
A Yes, and this approach is recommended. TanStack Query manages server-side state (API data), while Zustand manages client-side state (UI state). A common architecture: TanStack Query fetches and caches data → injects the data into the Zustand store for further processing → components read the final state from Zustand. The two are complementary, not substitutes for one another.

📖 Summary


📝 Exercises

  1. Create a user list component using useQuery: Use https://jsonplaceholder.typicode.com/users as the API, implement cache sharing (both components use the same queryKey to ensure the request is sent only once), and add a manual refresh button.
  2. Use useMutation to 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.
  3. 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.
Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏