Managing server state in React used to be a pain: you write fetch calls in useEffect, track loading and error states manually, and spend ages implementing caching, refetching, and retries. TanStack Query (formerly known as React Query) changes all that. It’s a lightweight library for fetching, caching, synchronizing, and updating async data without boilerplate or global state management.
What is TanStack Query?
In simple terms, TanStack Query helps you declaratively fetch and manage server data in React apps. Instead of writing countless useEffect hooks, you tell Query:
“Fetch this data, keep it fresh, and cache or refetch it when needed.”
And TanStack Query does the rest — caching, stale-data logic, background updates, retries, refetching on focus, paginated loads, and more, all with minimal configuration.
Installing TanStack Query
To get started:
# Using npm
npm install @tanstack/react-query
# Using pnpm
pnpm add @tanstack/react-query
# Using yarn
yarn add @tanstack/react-query
TanStack Query is compatible with React 18+ and works with both web and React Native.
Core Concepts
Query Client
First, we create a QueryClient — the central cache manager:
// src/queryClient.ts
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient()
Then we wrap our app with QueryClientProvider:
// src/App.tsx
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClient } from './queryClient'
import Todos from './Todos'
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<Todos />
</QueryClientProvider>
)
}
Basic Data Fetching with useQuery
At its core, TanStack Query replaces patterns like this:
// ❌ old way with useEffect (no cache or auto-refresh)
useEffect(() => {
fetch('/api/todos')
.then(res => res.json())
.then(setTodos)
.catch(setError)
}, [])
With TanStack Query’s useQuery, you get caching, status flags, and error handling for free:
// src/Todos.tsx
import { useQuery } from '@tanstack/react-query'
function fetchTodos() {
return fetch('/api/todos').then(res => res.json())
}
export default function Todos() {
const { data, isLoading, error } = useQuery({
queryKey: ['todos'], // unique cache key
queryFn: fetchTodos, // fetch function
})
if (isLoading) return <p>Loading…</p>
if (error) return <p>Error loading todos</p>
return (
<ul>
{data.map((todo: any) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)
}
👍 data, error, isLoading, isFetching, and more are automatically managed by TanStack Query.
TypeScript Support
TanStack Query is written in TypeScript and infers types very well. For example, if your fetch function returns a known type:
type Todo = {
id: number
title: string
completed: boolean
}
function fetchTodos(): Promise<Todo[]> {
return fetch('/api/todos').then(res => res.json())
}
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
// `data` is inferred as `Todo[] | undefined`
You can also specify types explicitly if you want better control:
useQuery<Todo[], Error>({
queryKey: ['todos'],
queryFn: fetchTodos,
})
This makes your app type-safe and predictable.
Mutations: Updating Server State
For POST/PUT/DELETE operations, TanStack Query provides useMutation, which makes updating data and handling side effects easier:
import { useMutation, useQueryClient } from '@tanstack/react-query'
function addTodo(newTodo: Partial<Todo>) {
return fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo),
}).then(res => res.json())
}
export default function NewTodoForm() {
const queryClient = useQueryClient()
const mutation = useMutation(addTodo, {
onSuccess: () => {
// Invalidate and refetch todos after add
queryClient.invalidateQueries(['todos'])
},
})
const handleSubmit = () => {
mutation.mutate({ title: 'New Task!' })
}
return <button onClick={handleSubmit}>Add Todo</button>
}
Why Use TanStack Query?
Here’s why it’s become one of the most recommended tools for managing server data in React:
- 🪄 Automatic caching & refetching
- ⚡ Background updates on focus
- 💡 Zero boilerplate for loading/error states
- 📚 Built-in pagination & infinite scrolling
- 🧠 Great TypeScript support
- 🔄 Mutations with cache invalidation
It removes the headaches of async data logic and lets you focus on building UI instead of infrastructure.
Common Patterns
Dependent Queries
You can delay a query until another one has loaded:
const { data: user } = useQuery([...])
const { data: profile } = useQuery({
queryKey: ['profile', user?.id],
queryFn: () => fetchProfile(user.id),
enabled: !!user
})
Infinite Scrolling
Using useInfiniteQuery lets you fetch pages of data as the user scrolls:
const {
data,
fetchNextPage,
hasNextPage,
} = useInfiniteQuery({...})
Wrapping Up
TanStack Query significantly improves how React apps handle server data — it’s declarative, efficient, and easy to adopt. It fills the gap between React’s UI model and async state management without forcing a global state library.
If you build any data-heavy UI, it’s worth adding TanStack Query to your stack.
