TanStack Query (React Query)
TanStack Query (formerly React Query) is the industry-standard library for managing server state in React applications. It solves the entire surface area of data fetching — loading states, error states, caching, background refetching, pagination, mutations, and synchronisation — so you don't have to write it yourself. If you've ever built the manual useEffect + fetch pattern, TanStack Query replaces hundreds of lines of that boilerplate with a few hook calls.
Installation & Setup
npm install @tanstack/react-query
Wrap your application with QueryClientProvider to give every component access to the query cache:
// main.jsx (or _app.tsx in Next.js Pages Router)
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // data is fresh for 5 minutes
retry: 2, // retry failed requests twice
},
},
})
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
)
}useQuery: Fetching Data
useQuery is the primary hook. It takes an object with a queryKey (cache identifier) and a queryFn (the async function that fetches the data):
import { useQuery } from '@tanstack/react-query'
async function fetchPosts() {
const res = await fetch('https://jsonplaceholder.typicode.com/posts')
if (!res.ok) throw new Error('Network response was not ok')
return res.json()
}
function PostList() {
const {
data: posts,
isLoading,
isError,
error,
isFetching, // true when a background refetch is in progress
refetch, // manually trigger a refetch
} = useQuery({
queryKey: ['posts'], // unique key for this query's cache entry
queryFn: fetchPosts,
})
if (isLoading) return <p>Loading posts…</p>
if (isError) return <p>Error: {error.message}</p>
return (
<div>
{isFetching && <span>Refreshing…</span>}
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
<button onClick={() => refetch()}>Refresh</button>
</div>
)
}Query Keys: The Cache Identity
The queryKey is how TanStack Query identifies entries in its cache. Keys are serialised arrays — include any variables the query depends on:
// Static query — same data every time
useQuery({ queryKey: ['posts'], queryFn: fetchPosts })
// Dynamic query — different data for each userId
useQuery({
queryKey: ['users', userId], // ['users', 1], ['users', 2] etc.
queryFn: () => fetchUser(userId),
enabled: !!userId, // don't run if userId is falsy
})
// Nested data — comments for a specific post
useQuery({
queryKey: ['posts', postId, 'comments'],
queryFn: () => fetchComments(postId),
})useMutation: Creating, Updating, Deleting
For operations that change server state (POST, PUT, PATCH, DELETE), use useMutation. After a successful mutation, invalidate the relevant queries so the cache refreshes automatically:
import { useMutation, useQueryClient } from '@tanstack/react-query'
async function createPost(newPost) {
const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newPost),
})
if (!res.ok) throw new Error('Failed to create post')
return res.json()
}
function CreatePostForm() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: createPost,
onSuccess: () => {
// Invalidate the posts cache → triggers a background refetch
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
onError: (err) => {
console.error('Mutation failed:', err.message)
},
})
function handleSubmit(e) {
e.preventDefault()
const formData = new FormData(e.target)
mutation.mutate({
title: formData.get('title'),
body: formData.get('body'),
userId: 1,
})
}
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Post title" required />
<textarea name="body" placeholder="Post body" />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating…' : 'Create Post'}
</button>
{mutation.isError && <p>Error: {mutation.error.message}</p>}
{mutation.isSuccess && <p>Post created!</p>}
</form>
)
}Stale-While-Revalidate & Background Refetching
TanStack Query implements a stale-while-revalidate strategy: it shows cached (possibly stale) data immediately, then fetches fresh data in the background and updates the UI when the new data arrives. This makes your app feel instant.
staleTime— how long data is considered fresh. During this period, no refetch happens even if the query is re-mounted. Default: 0 (always stale).gcTime(formerlycacheTime) — how long inactive cached data is kept in memory. Default: 5 minutes.refetchOnWindowFocus— automatically refetch when the user returns to the tab. Default: true.refetchInterval— poll the server on a fixed interval (e.g. every 30 seconds for a live feed).
useQuery({
queryKey: ['stockPrice', symbol],
queryFn: () => fetchStockPrice(symbol),
staleTime: 1000 * 30, // fresh for 30 seconds
refetchInterval: 1000 * 30, // poll every 30 seconds
refetchOnWindowFocus: true,
})Pagination with useInfiniteQuery
useInfiniteQuery handles infinite scroll and load-more pagination patterns:
import { useInfiniteQuery } from '@tanstack/react-query'
function InfinitePosts() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery({
queryKey: ['posts', 'infinite'],
queryFn: ({ pageParam = 1 }) =>
fetch(`/api/posts?page=${pageParam}&limit=10`)
.then(r => r.json()),
getNextPageParam: (lastPage, allPages) =>
lastPage.hasMore ? allPages.length + 1 : undefined,
})
if (isLoading) return <p>Loading…</p>
return (
<div>
{data.pages.map((page, i) => (
<div key={i}>
{page.posts.map(post => (
<p key={post.id}>{post.title}</p>
))}
</div>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading more…' : 'Load more'}
</button>
</div>
)
}Complete Posts CRUD Example
// postsApi.js
export const fetchPosts = () => fetch('/api/posts').then(r => r.json())
export const fetchPost = (id) => fetch(`/api/posts/${id}`).then(r => r.json())
export const createPost = (body) =>
fetch('/api/posts', { method: 'POST', body: JSON.stringify(body) }).then(r => r.json())
export const updatePost = ({ id, ...body }) =>
fetch(`/api/posts/${id}`, { method: 'PUT', body: JSON.stringify(body) }).then(r => r.json())
export const deletePost = (id) =>
fetch(`/api/posts/${id}`, { method: 'DELETE' })
// PostsPage.jsx
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { fetchPosts, deletePost } from './postsApi'
function PostsPage() {
const queryClient = useQueryClient()
const { data: posts = [], isLoading } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
const deleteMutation = useMutation({
mutationFn: deletePost,
onSuccess: (_, deletedId) => {
// Optimistic: remove from cache immediately without waiting for refetch
queryClient.setQueryData(['posts'], old =>
old.filter(p => p.id !== deletedId)
)
},
})
if (isLoading) return <p>Loading…</p>
return (
<ul>
{posts.map(post => (
<li key={post.id}>
{post.title}
<button
onClick={() => deleteMutation.mutate(post.id)}
disabled={deleteMutation.isPending}
>
Delete
</button>
</li>
))}
</ul>
)
}Feature | Manual useEffect | TanStack Query |
|---|---|---|
Loading state | Manual useState | isLoading / isPending |
Error handling | Manual try/catch | isError / error |
Caching | None | Automatic, configurable |
Deduplication | None | Same key = one request |
Background refetch | Manual | Automatic on focus/interval |
Optimistic updates | Complex manual code | setQueryData |
Pagination | Manual state management | useInfiniteQuery |
DevTools | None | React Query DevTools |