Skip to main content

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
info

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.

info

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.

KindExampleManagement tool
Client statemodal open/close, form input, theme settingsuseState, Zustand
Server stateuser list, post data, API responsesTanStack 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
info

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
  1. Set useQuery's queryKey and queryFn
  2. Switch the display based on the state with isPending, isError, and data
  3. 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.

FlagMeaningWhen to use
isPendingdata does not exist yet (during the first fetch, or after a failure)General skeleton/spinner display before data is fetched
isLoadingTrue only during the initial fetch (isPending && isFetching)When you want to show a spinner only on the initial fetch
isFetchingTrue including during background refetchesAn indicator display during refetching
isErrorAn error has occurredAn 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>
)
info

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 / isError inside the component, and can write on the premise that data always 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
info

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.

Aspectuse(Promise)useSuspenseQuery
CachingNone (the Promise is evaluated on each call)Yes (shared by queryKey)
Refetch / invalidationYou need to swap out the Promise manuallyRefetch declaratively with invalidateQueries
Background updatesNoneAutomatic with staleTime / refetchOnWindowFocus
UseResolving 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 }]
warning

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
})
OptionDescription
staleTimeThe time for which data is considered fresh (default: 0)
gcTimeThe 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
  1. Add staleTime: 5 * 60 * 1000 to useQuery's options
  2. Confirm the state changes between "fresh" and "stale" in DevTools
  3. 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
  1. Extract the part that calls useQuery into a separate function
  2. Give it a name that starts with use
  3. 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 with useMutation
  • Identify the cache with queryKey
  • Control cache strategy with staleTime and gcTime
  • Improve UX with optimistic updates
  • Extract into custom hooks for reuse

In the next chapter, you will learn about routing with React Router.