TanStack Query
TanStack React Query is a powerful library for managing server state in React applications. It simplifies data fetching, caching, synchronization, and updating while keeping your UI in sync with your backend APIs.
🚀 Why React Query?
- Eliminates manual state management for server data.
- Built-in caching and background updates.
- Automatic retry and error handling.
- Works seamlessly with REST, GraphQL, or any async function.
- Great developer experience (DevTools included).
🛠 Setup
Before you can use useQuery or useMutation, you need to configure a Query Client and wrap your app with
QueryClientProvider.
First, install React Query and (optionally) DevTools:
npm install @tanstack/react-query
npm install @tanstack/react-query-devtools
Or with Yarn / pnpm:
yarn add @tanstack/react-query @tanstack/react-query-devtools
# or
pnpm add @tanstack/react-query @tanstack/react-query-devtools
After installation, you need to configure a Query Client and wrap your app with QueryClientProvider:
import React from 'react'
import ReactDOM from 'react-dom/client'
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {ReactQueryDevtools} from '@tanstack/react-query-devtools'
import App from './App'
// Create a client instance with default options
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes "fresh"
gcTime: 1000 * 60 * 10, // 10 minutes before garbage collection
refetchOnWindowFocus: false, // don't auto refetch when window refocuses
retry: 2, // retry failed requests up to 2 times
},
mutations: {
retry: 1, // mutations retry once if failed
},
},
})
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App/>
<ReactQueryDevtools initialIsOpen={false}/>
</QueryClientProvider>
</React.StrictMode>
)
🔑 Core Concepts
1. useQuery
Queries are used to fetch and cache data.
import {useQuery} from '@tanstack/react-query'
import axios from 'axios'
async function fetchUsers() {
const {data} = await axios.get('/api/users')
return data
}
export default function Users() {
const {data, error, isLoading} = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
})
if (isLoading) return <p>Loading...</p>
if (error) return <p>Error loading users</p>
return (
<ul>
{data.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
Query keys can be simple or complex arrays to uniquely identify each query:
// Simple query key
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos
})
// Query key with variables
useQuery({
queryKey: ['todo', todoId],
queryFn: () => fetchTodoById(todoId)
})
// Complex query key with filters
useQuery({
queryKey: ['todos', {status: 'done', userId: 1}],
queryFn: () => fetchTodosByFilter({status: 'done', userId: 1})
})
2. useMutation
Mutations are used to create, update, or delete data.
import {useMutation, useQueryClient} from '@tanstack/react-query'
import axios from 'axios'
async function addUser(user) {
const {data} = await axios.post('/api/users', user)
return data
}
export default function AddUser() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: addUser,
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ['users']})
},
})
return (
<button
onClick={() => mutation.mutate({name: 'New User'})}
disabled={mutation.isLoading}
>
{mutation.isLoading ? 'Adding...' : 'Add User'}
</button>
)
}
3. invalidateQueries
Keeps cache fresh by refetching queries.
queryClient.invalidateQueries({queryKey: ['users']})
4. Query States
- isLoading
- isFetching
- isError
- isSuccess
const {data, isLoading, isError, error, isFetching} = useQuery({...})
For all other states, refer to the official docs.
5. useSuspenseQuery
- Works with React Suspense.
- Instead of returning loading/error states, it throws promises that Suspense boundaries can handle.
import {useSuspenseQuery} from '@tanstack/react-query'
function UserList() {
const {data} = useSuspenseQuery({
queryKey: ['users'],
queryFn: fetchUsers,
})
return (
<ul>
{data.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
)
}
function App() {
return (
<React.Suspense fallback={<p>Loading...</p>}>
<UserList/>
</React.Suspense>
)
}
6. useQueries
- Run multiple queries in parallel.
- Useful when you need several independent datasets.
import {useQueries} from '@tanstack/react-query'
const results = useQueries({
queries: [
{queryKey: ['user', 1], queryFn: () => fetchUser(1)},
{queryKey: ['posts'], queryFn: fetchPosts},
],
})
const [userResult, postsResult] = results
7. useSuspenseQueries
- Same as useQueries but with Suspense.
import {useSuspenseQueries} from '@tanstack/react-query'
const results = useSuspenseQueries({
queries: [
{queryKey: ['user', 1], queryFn: () => fetchUser(1)},
{queryKey: ['posts'], queryFn: fetchPosts},
],
})
const [userResult, postsResult] = results
8. data alias
- Every query result has both
.dataand.dataUpdatedAt. - You can alias the data prop when destructuring to avoid naming conflicts:
const {data: users} = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
})
const {data: posts} = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
9. Enabled Queries
By default, queries run automatically.
With enabled, you can conditionally fetch data.
const {data, isLoading} = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
enabled: !!userId, // Only runs if userId is defined
})
enabled: false→ query does not run automatically.- Useful for dependent queries (fetch posts after user is loaded).
const {data: user} = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
const {data: posts} = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetchPosts(user!.id),
enabled: !!user, // wait until user exists
})
10. Caching & Data Freshness
React Query caches every query result in memory.
Understanding when it reuses cache vs. when it refetches is key.
🔑 staleTime
- How long the data is considered fresh.
- Default:
0→ data is immediately stale. - If data is fresh, React Query will not refetch automatically.
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 1000 * 60, // 1 minute
})
🔑 gcTime (cacheTime)
- How long unused (inactive) data stays in cache before being garbage collected.
- Default:
5 minutes. - If another component mounts with the same queryKey during this time, the cached data is reused.
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
cacheTime: 1000 * 60 * 10, // 10 minutes
})
🕒 Timeline Example
With staleTime = 1 min and cacheTime = 5 min:
0s ─── Fetch API (first time) ───> Cached (Fresh ✅)
↓
0s - 60s → Fresh (no refetch)
↓
60s → Data becomes Stale (still cached ❗, may refetch if needed)
↓
60s - 300s → Stale but available in cache
↓
300s → cacheTime expires → Data removed from cache 🗑
↓
Next mount → API fetch again
Summary
staleTime→ How long data is considered fresh.cacheTime→ How long inactive data remains in memory.- Stale ≠ deleted: stale means “old but cached”. Deleted happens only after
cacheTime.
⚡ Advanced Features
Refetching
useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
refetchOnWindowFocus: true,
refetchInterval: 5000,
})
Caching
useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
staleTime: 1000 * 60,
gcTime: 1000 * 60 * 5,
})
Pagination & Infinite Queries
import {useInfiniteQuery} from '@tanstack/react-query'
async function fetchPosts({pageParam = 1}) {
const res = await fetch(`/api/posts?page=${pageParam}`)
return res.json()
}
export default function Posts() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
getNextPageParam: (lastPage) => lastPage.nextPage ?? false,
})
return (
<div>
{data?.pages.map((page, i) => (
<React.Fragment key={i}>
{page.items.map(post => (
<p key={post.id}>{post.title}</p>
))}
</React.Fragment>
))}
<button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage}>
{isFetchingNextPage ? 'Loading more...' : 'Load More'}
</button>
</div>
)
}
Optimistic Updates
const mutation = useMutation({
mutationFn: updateUser,
onMutate: async (newUser) => {
await queryClient.cancelQueries({queryKey: ['users']})
const previousUsers = queryClient.getQueryData(['users'])
queryClient.setQueryData(['users'], (old) =>
old.map(user =>
user.id === newUser.id ? {...user, ...newUser} : user
)
)
return {previousUsers}
},
onError: (err, newUser, context) => {
queryClient.setQueryData(['users'], context.previousUsers)
},
onSettled: () => {
queryClient.invalidateQueries({queryKey: ['users']})
},
})
Typescript Support
👉 In modern React Query with TypeScript, you often don’t need useEffect to react to query changes. Why?
- React Query already handles fetching lifecycle (loading, error, success).
- If you need to run something after success, you can use
onSuccessinuseQueryoptions instead of manually checking withuseEffect. - That makes your code cleaner and more declarative.
Here’s a typed query example fetching from https://jsonplaceholder.typicode.com/todos:
import {useQuery} from '@tanstack/react-query'
import axios from 'axios'
// Define a TypeScript type for the Todo
type Todo = {
userId: number
id: number
title: string
completed: boolean
}
// Fetcher function with typing
const fetchTodos = async (): Promise<Todo[]> => {
const {data} = await axios.get<Todo[]>('https://jsonplaceholder.typicode.com/todos')
return data
}
export default function Todos() {
const {data: todos, isLoading, isError, error} = useQuery<Todo[]>({
queryKey: ['todos'],
queryFn: fetchTodos,
onSuccess: (data) => {
console.log('Fetched todos:', data.length)
},
})
if (isLoading) return <p>Loading...</p>
if (isError) return <p>Error: {(error as Error).message}</p>
return (
<ul>
{todos?.slice(0, 5).map(todo => (
<li key={todo.id}>
{todo.title} {todo.completed ? '✅' : '❌'}
</li>
))}
</ul>
)
}
🔑 Key Points:
- Generics in
useQuery<Todo[]>tell React Query what type the data should be. - Axios also uses
<Todo[]>so the compiler checks the API response matches our type. onSuccessreplaces the old pattern ofuseEffectto “react to data being ready”.