Data fetching (TanStack Query)
In this chapter, you learn "TanStack Query," the de facto standard for server-state management.
What you learn in this chapter
- TanStack Query basics (useQuery, useMutation)
- Cache management with queryKey
- Cache strategies (staleTime, gcTime)
- Optimistic updates and extracting custom hooks
This chapter uses the project created in Chapter 3. Start the dev server with npm run dev and learn while writing code.
What is TanStack Query
TanStack Query (formerly React Query) is a library that manages fetching, caching, and synchronizing data from the server.
You can also do data fetching with the useEffect learned in Chapter 11, but the main reasons to use TanStack Query are as follows:
- Less boilerplate: no need to manage loading/error/data state yourself
- Automatic caching: prevents duplicate requests for the same data
- Background updates: automatically fetches the latest data on window focus
- Optimistic updates: updates the UI first to improve UX
Server state and client state
In React development, it is easier to organize "state" by dividing it into two kinds.
| Kind | Example | Management tool |
|---|---|---|
| Client state | modal open/close, form input, theme settings | useState, Zustand |
| Server state | user list, post data, API responses | TanStack Query |
Server state is "data that you do not own," and it may be changed by other users. TanStack Query is optimized for this characteristic.
Main features
- Automatic caching
- Background updates
- Deduplication of requests
- Managing loading/error states
- Support for pagination and infinite scroll
Setup
npm install @tanstack/react-query
This book uses TanStack Query v5 (5.101.0 at the time of writing). If your major version differs, the API may have changed.
Configuring the provider
// main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
)
Basic usage
useQuery (fetching data)
import { useQuery } from '@tanstack/react-query'
type User = {
id: number
name: string
email: string
}
function UserProfile({ userId }: { userId: number }) {
const { data, isPending, isError, error } = useQuery({
queryKey: ['user', userId],
queryFn: async () => {
const response = await fetch(`/api/users/${userId}`)
if (!response.ok) throw new Error('Failed to fetch')
return response.json() as Promise<User>
}
})
if (isPending) return <p>Loading...</p>
if (isError) return <p>Error: {error.message}</p>
return (
<div>
<h2>{data?.name}</h2>
<p>{data?.email}</p>
</div>
)
}
Try it: implement useQuery
Fetch a user list from the JSONPlaceholder API (https://jsonplaceholder.typicode.com/users) and display the loading and error states appropriately.
Hint
- Set
useQuery'squeryKeyandqueryFn - Switch the display based on the state with
isPending,isError, anddata - Do not forget error handling
Answer and explanation
// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import './index.css'
const queryClient = new QueryClient()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>
)
// src/App.tsx
import { useQuery } from '@tanstack/react-query'
type User = {
id: number
name: string
email: string
phone: string
website: string
}
function App() {
const { data, isPending, isError, error } = useQuery({
queryKey: ['users'],
queryFn: async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/users')
if (!response.ok) {
throw new Error('Failed to fetch the user list')
}
return response.json() as Promise<User[]>
}
})
if (isPending) {
return (
<div className="p-8">
<p className="text-gray-500">Loading...</p>
</div>
)
}
if (isError) {
return (
<div className="p-8">
<p className="text-red-500">Error: {error.message}</p>
</div>
)
}
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">User list</h1>
<ul className="space-y-2">
{data?.map((user) => (
<li key={user.id} className="p-4 bg-gray-100 rounded">
<p className="font-bold">{user.name}</p>
<p className="text-gray-600 text-sm">{user.email}</p>
</li>
))}
</ul>
</div>
)
}
export default App
useQuery identifies the cache with queryKey and fetches data with queryFn. It displays the UI based on the state with isPending, isError, and data.
Choosing between the status flags (isPending / isLoading / isFetching)
In TanStack Query v5, there are multiple flags that represent a query's state. They are easy to confuse because their uses are similar, but their meanings are clearly different.
| Flag | Meaning | When to use |
|---|---|---|
isPending | data does not exist yet (during the first fetch, or after a failure) | General skeleton/spinner display before data is fetched |
isLoading | True only during the initial fetch (isPending && isFetching) | When you want to show a spinner only on the initial fetch |
isFetching | True including during background refetches | An indicator display during refetching |
isError | An error has occurred | An error message display |
const { data, isPending, isFetching, isError, error } = useQuery({...})
if (isPending) return <Spinner /> // on the initial display
if (isError) return <ErrorMessage error={error} />
return (
<div>
{isFetching && <span>Updating...</span>} {/* shown only during refetch */}
<UserProfile user={data} />
</div>
)
In versions before v5, isLoading represented "the state where data does not exist yet," but in v5 the same meaning was reorganized so that isPending carries it. isLoading itself remains as "true only on the initial load," but unless you have a special reason, using isPending is recommended.
useSuspenseQuery (Suspense integration)
Since React 18, <Suspense> has been officially supported as a loading boundary for data fetching. In TanStack Query, using useSuspenseQuery lets you delegate the loading display and error handling to the parent component.
For the <ErrorBoundary> in the code example, use the community-standard react-error-boundary package.
npm install react-error-boundary
import { useSuspenseQuery } from '@tanstack/react-query'
import { Suspense } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
function UserProfile({ userId }: { userId: number }) {
// data always exists (the loading/error states are handled at a higher level)
const { data } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
})
return <h2>{data.name}</h2>
}
function App() {
return (
<ErrorBoundary fallback={<p>An error occurred</p>}>
<Suspense fallback={<p>Loading...</p>}>
<UserProfile userId={1} />
</Suspense>
</ErrorBoundary>
)
}
The benefits of using useSuspenseQuery are as follows.
- You no longer need to check
isPending/isErrorinside the component, and can write on the premise thatdataalways exists - Because you can await multiple queries together with
<Suspense>, "display only after everything is ready" is easy to build - Combining it with
<ErrorBoundary>lets you write error handling declaratively
useSuspenseQuery does not have an enabled option (you cannot disable a query conditionally). Design it so that the query itself is not executed depending on routing, or use the regular useQuery when enabled is needed.
The difference from React 19's use(Promise)
In React 19, the use hook became available, which receives an arbitrary Promise and resolves its result at a Suspense boundary. They look similar at first glance, but their roles differ.
| Aspect | use(Promise) | useSuspenseQuery |
|---|---|---|
| Caching | None (the Promise is evaluated on each call) | Yes (shared by queryKey) |
| Refetch / invalidation | You need to swap out the Promise manually | Refetch declaratively with invalidateQueries |
| Background updates | None | Automatic with staleTime / refetchOnWindowFocus |
| Use | Resolving a Promise passed from Server Components, etc. | General server-data management in an SPA |
When handling server state in an SPA, useSuspenseQuery, which has caching and a refetch strategy, is the first candidate. use(Promise) shines in designs where you receive a Promise as props from a parent component or Server Components.
Designing the queryKey
// A simple key
queryKey: ['todos']
// With a parameter
queryKey: ['todo', todoId]
// With filter conditions
queryKey: ['todos', { status: 'completed', page: 1 }]
The queryKey is the cache identifier. Queries with the same key share the same data. When a parameter changes, always include it in the key.
// NG: the cache is shared even when userId changes
queryKey: ['user']
// OK: a separate cache for each userId
queryKey: ['user', userId]
useMutation (updating data)
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
function CreateTodo() {
const queryClient = useQueryClient()
const [text, setText] = useState('')
const mutation = useMutation({
mutationFn: async (newTodo: { text: string }) => {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo)
})
return response.json()
},
onSuccess: () => {
// Invalidate the cache and refetch
queryClient.invalidateQueries({ queryKey: ['todos'] })
setText('')
}
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
mutation.mutate({ text })
}
return (
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
disabled={mutation.isPending}
/>
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Adding...' : 'Add'}
</button>
{mutation.isError && <p>Error: {mutation.error.message}</p>}
</form>
)
}
Cache strategies
staleTime and gcTime
const { data } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
staleTime: 5 * 60 * 1000, // fresh for 5 minutes (does not refetch)
gcTime: 30 * 60 * 1000 // keep the cache for 30 minutes
})
| Option | Description |
|---|---|
staleTime | The time for which data is considered fresh (default: 0) |
gcTime | The time unused data stays in the cache (default: 5 minutes) |
Controlling refetch
const { data } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
refetchOnWindowFocus: false, // disable refetch on window focus
refetchOnMount: false, // disable refetch on mount
refetchInterval: 30000 // auto-refetch every 30 seconds
})
Try it: configure a cache strategy
Set staleTime to 5 minutes and confirm the cache behavior in DevTools.
Hint
- Add
staleTime: 5 * 60 * 1000touseQuery's options - Confirm the state changes between "fresh" and "stale" in DevTools
- Confirm the timing of API calls with
console.log
Answer and explanation
// src/hooks/useUsers.ts
import { useQuery } from '@tanstack/react-query'
type User = {
id: number
name: string
email: string
phone: string
website: string
}
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: async () => {
console.log('Fetching users...') // confirm the API call
const response = await fetch('https://jsonplaceholder.typicode.com/users')
if (!response.ok) {
throw new Error('Failed to fetch the user list')
}
return response.json() as Promise<User[]>
},
staleTime: 5 * 60 * 1000, // fresh for 5 minutes
gcTime: 30 * 60 * 1000 // keep the cache for 30 minutes
})
}
// src/main.tsx (add DevTools)
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import App from './App'
import './index.css'
const queryClient = new QueryClient()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</StrictMode>
)
When you set staleTime, the query is in the "fresh" state within that time and is not refetched. Opening DevTools lets you confirm the query's state (fresh/stale) and the cache contents. After 5 minutes pass, focusing the screen automatically runs a background update. Adding console.log lets you confirm the timing at which API calls actually occur.
Practical patterns
Unified handling of loading and error
type QueryResult<T> = {
data: T | undefined
isPending: boolean
isError: boolean
error: Error | null
}
function QueryWrapper<T>({
query,
children
}: {
query: QueryResult<T>
children: (data: T) => React.ReactNode
}) {
if (query.isPending) return <LoadingSpinner />
if (query.isError) return <ErrorMessage error={query.error} />
if (!query.data) return null
return <>{children(query.data)}</>
}
// Usage
function UserList() {
const query = useQuery({ queryKey: ['users'], queryFn: fetchUsers })
return (
<QueryWrapper query={query}>
{(users) => (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</QueryWrapper>
)
}
Dependent queries
function UserPosts({ userId }: { userId: number }) {
// First fetch the user
const userQuery = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId)
})
// Once the user is fetched, fetch the posts
const postsQuery = useQuery({
queryKey: ['posts', userId],
queryFn: () => fetchPostsByUser(userId),
enabled: !!userQuery.data // run after userQuery succeeds
})
// ...
}
Optimistic updates
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
// Cancel in-flight queries
await queryClient.cancelQueries({ queryKey: ['todos'] })
// Save the previous value
const previousTodos = queryClient.getQueryData(['todos'])
// Update optimistically
queryClient.setQueryData(['todos'], (old: Todo[]) =>
old.map((todo) =>
todo.id === newTodo.id ? newTodo : todo
)
)
return { previousTodos }
},
onError: (err, newTodo, context) => {
// Roll back on error
queryClient.setQueryData(['todos'], context?.previousTodos)
},
onSettled: () => {
// Invalidate the cache after completion
queryClient.invalidateQueries({ queryKey: ['todos'] })
}
})
DevTools
npm install @tanstack/react-query-devtools
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
function App() {
return (
<QueryClientProvider client={queryClient}>
<MyApp />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
Extracting into custom hooks
Use the custom hook pattern learned in Chapter 15 to make query logic reusable.
function useUser(userId: number) {
return useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000
})
}
function useUpdateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateUser,
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['user', data.id] })
}
})
}
// Usage
function UserProfile({ userId }: { userId: number }) {
const { data: user, isPending } = useUser(userId)
const updateMutation = useUpdateUser()
// ...
}
Try it: extract a custom hook
Extract the user-fetching query into a custom hook called useUsers.
Hint
- Extract the part that calls
useQueryinto a separate function - Give it a name that starts with
use - Consolidate the type definitions inside the hook too
Answer and explanation
// src/hooks/useUsers.ts
import { useQuery } from '@tanstack/react-query'
type User = {
id: number
name: string
email: string
phone: string
website: string
}
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/users')
if (!response.ok) {
throw new Error('Failed to fetch the user list')
}
return response.json() as Promise<User[]>
}
})
}
// A hook to fetch a specific user
export function useUser(userId: number) {
return useQuery({
queryKey: ['user', userId],
queryFn: async () => {
const response = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
if (!response.ok) {
throw new Error('Failed to fetch the user')
}
return response.json() as Promise<User>
},
enabled: userId > 0 // run only when userId is valid
})
}
// src/App.tsx
import { useUsers } from './hooks/useUsers'
function App() {
const { data: users, isPending, isError, error } = useUsers()
if (isPending) {
return <div className="p-8"><p>Loading...</p></div>
}
if (isError) {
return <div className="p-8"><p className="text-red-500">Error: {error.message}</p></div>
}
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">User list</h1>
<ul className="space-y-2">
{users?.map((user) => (
<li key={user.id} className="p-4 bg-gray-100 rounded">
<p className="font-bold">{user.name}</p>
<p className="text-gray-600 text-sm">{user.email}</p>
</li>
))}
</ul>
</div>
)
}
export default App
By extracting into a custom hook, you can reuse query logic across multiple components. Consolidating the type definitions inside the hook keeps the calling side simple.
Summary
- TanStack Query is the de facto standard for server-state management
- Fetch data with
useQuery, update withuseMutation - Identify the cache with
queryKey - Control cache strategy with
staleTimeandgcTime - Improve UX with optimistic updates
- Extract into custom hooks for reuse
In the next chapter, you will learn about routing with React Router.