
- What is TanStack Query (React Query)?
- Why Use TanStack Query for React Development?
- Getting Started: Installation and Setup
- Understanding useQuery: The Foundation
- Handling Mutations: Creating, Updating, and Deleting Data
- Advanced TanStack Query Patterns
- Configuration and Optimization
- Common Mistakes to Avoid
- DevTools: Debugging Made Easy
- Real-World Example: Complete Todo Application
- Performance Best Practices
- TypeScript Integration
- Migration from Redux
- When to Use TanStack Query
- Conclusion
- Quick Reference Cheat Sheet
- Additional Resources
What is TanStack Query (React Query)?
TanStack Query, previously known as React Query, is a powerful library that simplifies fetching, caching, synchronizing, and updating server state in React applications. If you’ve been manually managing API calls, loading states, and cache logic in your React projects, TanStack Query is about to transform your development workflow.
The library eliminates the need to write reducers, caching logic, timers, and complex retry mechanisms, allowing you to focus on building features instead of managing infrastructure code.
Why Use TanStack Query for React Development?
Key Benefits
1. Automatic Caching and Background Updates
TanStack Query automatically caches your data and performs silent background refetches on events like component remount or window focus to keep information current. This means your users see instant data from cache while fresh information loads in the background.
2. Simplified Code Architecture
For applications using Redux solely for server data management, React Query can entirely replace Redux with a much simpler and more powerful solution. This reduction in boilerplate code makes your application easier to maintain and understand.
3. Enterprise-Ready Features
TanStack Query powers applications from weekend projects to enterprise systems like Walmart, offering dedicated devtools, infinite-loading APIs, and robust mutation tools.
Core Features at a Glance
- Automatic request deduplication
- Smart retry logic and error handling
- Polling and real-time queries
- Pagination and infinite scroll support
- Optimistic updates
- Request cancellation
- Offline support
- TypeScript support
Getting Started: Installation and Setup
Step 1: Install TanStack Query
First, install the package in your React project:
npm install @tanstack/react-query
# or
yarn add @tanstack/react-query
Step 2: Setup Query Client Provider
Every React application using TanStack Query needs a QueryClient instance wrapped around your component tree with QueryClientProvider:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
// Create a client instance
const queryClient = new QueryClient()
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourComponents />
</QueryClientProvider>
)
}
Understanding useQuery: The Foundation
The useQuery hook is your primary tool for fetching data in TanStack Query. It requires two essential parameters:
Basic useQuery Syntax
import { useQuery } from '@tanstack/react-query'
function TodosList() {
const { data, isPending, error } = useQuery({
queryKey: ['todos'],
queryFn: () => fetch('/api/todos').then(res => res.json())
})
if (isPending) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return (
<ul>
{data.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)
}
Understanding Query Keys
Query keys serve as unique identifiers used internally for refetching, caching, and sharing queries throughout your application. They can be simple strings or arrays for more complex scenarios:
// Simple key
queryKey: ['todos']
// With parameters
queryKey: ['todos', userId]
// With filters
queryKey: ['todos', { status: 'completed', page: 1 }]
Query States Explained
A query exists in specific states: isPending when no data is available yet, isError when the fetch fails, and isSuccess when data is successfully retrieved.
Additional state properties include:
data: The actual response dataerror: Error object if the query failedisFetching: True during any fetch operation, including background refetchesisLoading: True only during the initial fetch with no cached data
Handling Mutations: Creating, Updating, and Deleting Data
While queries are for reading data, mutations handle write operations. The useMutation hook manages POST, PUT, PATCH, and DELETE requests.
Basic Mutation Example
import { useMutation, useQueryClient } from '@tanstack/react-query'
function AddTodo() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: (newTodo) => {
return fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
headers: { 'Content-Type': 'application/json' }
})
},
onSuccess: () => {
// Invalidate and refetch todos
queryClient.invalidateQueries({ queryKey: ['todos'] })
}
})
return (
<button
onClick={() => mutation.mutate({
title: 'New Todo',
completed: false
})}
disabled={mutation.isPending}
>
{mutation.isPending ? 'Adding...' : 'Add Todo'}
</button>
)
}
Query Invalidation
Query invalidation marks cached data as stale and triggers automatic refetches, ensuring your application displays current information after mutations.
Advanced TanStack Query Patterns
1. Dependent Queries
Execute queries based on previous query results:
function UserPosts({ userId }) {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId)
})
const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetchUserPosts(user.id),
enabled: !!user?.id // Only run when user is available
})
return <PostsList posts={posts} />
}
2. Pagination
function Posts() {
const [page, setPage] = useState(1)
const { data, isPending } = useQuery({
queryKey: ['posts', page],
queryFn: () => fetchPosts(page),
keepPreviousData: true
})
return (
<>
<PostsList posts={data?.posts} />
<button
onClick={() => setPage(prev => prev - 1)}
disabled={page === 1}
>
Previous
</button>
<button
onClick={() => setPage(prev => prev + 1)}
disabled={!data?.hasMore}
>
Next
</button>
</>
)
}
3. Infinite Queries
Perfect for infinite scroll implementations:
import { useInfiniteQuery } from '@tanstack/react-query'
function InfinitePosts() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam),
getNextPageParam: (lastPage, pages) => {
return lastPage.hasMore ? pages.length + 1 : undefined
}
})
return (
<>
{data?.pages.map((page, i) => (
<div key={i}>
{page.posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
</>
)
}
Configuration and Optimization
Global Query Defaults
Set default behavior for all queries:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
cacheTime: 1000 * 60 * 10, // 10 minutes
retry: 3,
refetchOnWindowFocus: false
}
}
})
Optimistic Updates
Provide instant feedback by updating the UI before server confirmation:
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (updatedTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previousTodos = queryClient.getQueryData(['todos'])
queryClient.setQueryData(['todos'], (old) =>
old.map(todo =>
todo.id === updatedTodo.id ? updatedTodo : todo
)
)
return { previousTodos }
},
onError: (err, updatedTodo, context) => {
queryClient.setQueryData(['todos'], context.previousTodos)
}
})
Common Mistakes to Avoid
1. Forgetting the Provider
The most frequent error is forgetting to wrap components with QueryClientProvider, causing hooks to fail silently. Always ensure your provider wraps the entire component tree.
2. Not Understanding Stale Time
By default, data is considered stale immediately, meaning TanStack Query will refetch it on remount or window focus. Configure staleTime to control this behavior for your specific use case.
3. Mixing Server State with Client State
Avoid using Redux or Context API for server data when using React Query, as this creates duplicated logic and confusion. Let TanStack Query handle server state completely.
DevTools: Debugging Made Easy
Install and use React Query DevTools for visibility into your queries:
npm install @tanstack/react-query-devtools
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
Real-World Example: Complete Todo Application
Here’s a comprehensive example combining queries and mutations:
import {
useQuery,
useMutation,
useQueryClient
} from '@tanstack/react-query'
function TodoApp() {
const queryClient = useQueryClient()
// Fetch todos
const { data: todos, isPending } = useQuery({
queryKey: ['todos'],
queryFn: async () => {
const res = await fetch('/api/todos')
return res.json()
}
})
// Add todo mutation
const addMutation = useMutation({
mutationFn: (newTodo) =>
fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
headers: { 'Content-Type': 'application/json' }
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
}
})
// Delete todo mutation
const deleteMutation = useMutation({
mutationFn: (id) =>
fetch(`/api/todos/${id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
}
})
// Toggle complete mutation
const toggleMutation = useMutation({
mutationFn: ({ id, completed }) =>
fetch(`/api/todos/${id}`, {
method: 'PATCH',
body: JSON.stringify({ completed }),
headers: { 'Content-Type': 'application/json' }
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
}
})
if (isPending) return <div>Loading todos...</div>
return (
<div>
<h1>My Todo List</h1>
<form onSubmit={(e) => {
e.preventDefault()
const formData = new FormData(e.target)
addMutation.mutate({
title: formData.get('title'),
completed: false
})
e.target.reset()
}}>
<input
name="title"
placeholder="Add new todo"
required
/>
<button type="submit">Add</button>
</form>
<ul>
{todos?.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={(e) => toggleMutation.mutate({
id: todo.id,
completed: e.target.checked
})}
/>
<span style={{
textDecoration: todo.completed ? 'line-through' : 'none'
}}>
{todo.title}
</span>
<button
onClick={() => deleteMutation.mutate(todo.id)}
>
Delete
</button>
</li>
))}
</ul>
</div>
)
}
Performance Best Practices
1. Use Query Keys Effectively
Structure your query keys hierarchically for better cache management:
// Good structure
['todos'] // All todos
['todos', todoId] // Specific todo
['todos', { status: 'active' }] // Filtered todos
['todos', todoId, 'comments'] // Todo's comments
2. Implement Stale-While-Revalidate
Balance fresh data with performance:
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 1000 * 60 * 5, // Fresh for 5 minutes
cacheTime: 1000 * 60 * 30 // Keep in cache for 30 minutes
})
3. Prefetch Data
Improve perceived performance by prefetching:
const queryClient = useQueryClient()
function TodoPreview({ todoId }) {
const prefetch = () => {
queryClient.prefetchQuery({
queryKey: ['todo', todoId],
queryFn: () => fetchTodo(todoId)
})
}
return (
<div onMouseEnter={prefetch}>
<Link to={`/todos/${todoId}`}>View Todo</Link>
</div>
)
}
TypeScript Integration
TanStack Query offers excellent TypeScript support:
interface Todo {
id: number
title: string
completed: boolean
}
function useTodos() {
return useQuery<Todo[], Error>({
queryKey: ['todos'],
queryFn: async () => {
const response = await fetch('/api/todos')
if (!response.ok) throw new Error('Failed to fetch')
return response.json()
}
})
}
function useAddTodo() {
return useMutation<Todo, Error, Omit<Todo, 'id'>>({
mutationFn: async (newTodo) => {
const response = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo)
})
return response.json()
}
})
}
Migration from Redux
If you’re currently using Redux for server state, here’s how to migrate:
Before (Redux):
// Actions, reducers, thunks, loading states...
// 100+ lines of boilerplate
After (TanStack Query):
const { data, isPending } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos
})
// That's it!
When to Use TanStack Query
Perfect For:
- RESTful API integration
- GraphQL queries (with custom fetchers)
- Server state management
- Real-time data synchronization
- Applications with frequent data updates
Not Ideal For:
- Local/client state management (use useState, useReducer, or Zustand)
- Static data that never changes
- Simple apps with minimal API calls
Conclusion
TanStack Query revolutionizes server state management in React applications by providing a powerful, declarative API that handles caching, synchronization, and updates automatically. By eliminating boilerplate code and offering enterprise-grade features, it allows developers to build faster, more responsive applications with significantly less code.
TanStack Query helps remove complex code from applications, makes them more maintainable, improves end-user experience with faster and more responsive interfaces, and can enhance bandwidth efficiency and memory performance.
Quick Reference Cheat Sheet
// Basic Query
useQuery({ queryKey: ['key'], queryFn: fetchFn })
// Mutation
useMutation({ mutationFn: mutateFn, onSuccess: callback })
// Invalidate Cache
queryClient.invalidateQueries({ queryKey: ['key'] })
// Prefetch
queryClient.prefetchQuery({ queryKey: ['key'], queryFn: fetchFn })
// Get Cached Data
queryClient.getQueryData(['key'])
// Set Cached Data
queryClient.setQueryData(['key'], newData)
// Cancel Query
queryClient.cancelQueries({ queryKey: ['key'] })
Additional Resources
- Official TanStack Query Documentation
- GitHub Repository
- TanStack Query Discord Community
- React Query DevTools
Start implementing TanStack Query in your React projects today and experience the difference in developer productivity and application performance. The library’s intuitive API, combined with its powerful features, makes it an essential tool for modern React development.


