TanStack Query: Server State Management for React (and Beyond)
Most React state management debates are actually about the wrong problem. Libraries like Redux and Zustand manage client state well — UI toggles, form values, selected items. But the biggest challenge in most applications is server state: data that lives on a remote server and needs to be fetched, cached, synchronized, and updated.
TanStack Query (formerly React Query) is built specifically for server state. It handles caching, background refetching, loading/error states, pagination, optimistic updates, and cache invalidation — the things that get messy when you try to manage server data with useState and useEffect.
The Problem TanStack Query Solves
Without TanStack Query, fetching data in React typically looks like:
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setLoading(true);
fetchUser(userId)
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <Spinner />;
if (error) return <Error />;
return <div>{user?.name}</div>;
}
Issues with this approach:
- No caching — navigating away and back re-fetches
- No background synchronization — stale data displayed
- Manual loading/error state management
- Race conditions when
userIdchanges quickly - No deduplication — multiple components fetching the same data
TanStack Query's version:
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
if (isLoading) return <Spinner />;
if (error) return <Error />;
return <div>{user?.name}</div>;
}
The library handles caching, deduplication, background refetching, and race conditions automatically.
Installation
npm install @tanstack/react-query
# Optional dev tools
npm install @tanstack/react-query-devtools
Setup
Wrap your app with QueryClientProvider:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 10, // 10 minutes (was cacheTime in v4)
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
The devtools (development only) show cached queries, their state, and data — invaluable for debugging.
Queries
useQuery fetches and caches data:
import { useQuery } from '@tanstack/react-query';
function PostList() {
const { data, isLoading, isError, error } = useQuery({
queryKey: ['posts'],
queryFn: async () => {
const response = await fetch('/api/posts');
if (!response.ok) throw new Error('Network error');
return response.json() as Promise<Post[]>;
},
});
if (isLoading) return <div>Loading...</div>;
if (isError) return <div>Error: {error.message}</div>;
return (
<ul>
{data.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
Query Keys
The queryKey identifies cache entries. It's used to:
- Identify cached data to return on re-render
- Invalidate related queries after mutations
- Deduplicate concurrent requests
Keys should be serializable and describe the data:
// Simple key
queryKey: ['posts']
// Key with parameters — different cache entry per ID
queryKey: ['post', postId]
// Key with filters
queryKey: ['posts', { status: 'published', page: 2 }]
Dependent Queries
Wait for one query's result before fetching another:
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetchUserPosts(user!.id),
enabled: !!user, // only runs when user is available
});
Mutations
useMutation handles create/update/delete operations:
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreatePost() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newPost: CreatePostInput) =>
fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(newPost),
}).then(r => r.json()),
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
onError: (error) => {
console.error('Failed to create post:', error);
},
});
return (
<form onSubmit={(e) => {
e.preventDefault();
mutation.mutate({ title: 'New Post', content: '...' });
}}>
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
);
}
Optimistic Updates
Update the cache immediately, then revert if the mutation fails:
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (updatedTodo) => {
// Cancel any in-flight queries
await queryClient.cancelQueries({ queryKey: ['todos', updatedTodo.id] });
// Snapshot previous value
const previousTodo = queryClient.getQueryData(['todos', updatedTodo.id]);
// Optimistically update
queryClient.setQueryData(['todos', updatedTodo.id], updatedTodo);
// Return context with previous value
return { previousTodo };
},
onError: (err, updatedTodo, context) => {
// Revert on error
queryClient.setQueryData(
['todos', updatedTodo.id],
context?.previousTodo
);
},
onSettled: (updatedTodo) => {
// Refetch after success or error
queryClient.invalidateQueries({ queryKey: ['todos', updatedTodo.id] });
},
});
Pagination
function PostList() {
const [page, setPage] = useState(1);
const { data, isPlaceholderData } = useQuery({
queryKey: ['posts', page],
queryFn: () => fetchPosts(page),
placeholderData: keepPreviousData, // show previous page while next loads
});
return (
<>
{data?.posts.map(post => <Post key={post.id} post={post} />)}
<button
onClick={() => setPage(p => p - 1)}
disabled={page === 1}
>
Previous
</button>
<button
onClick={() => setPage(p => p + 1)}
disabled={isPlaceholderData || !data?.hasMore}
>
Next
</button>
</>
);
}
Infinite Queries
For infinite scroll or "load more" patterns:
import { useInfiniteQuery } from '@tanstack/react-query';
function InfinitePostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts', 'infinite'],
queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam }),
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
return (
<>
{data?.pages.flatMap(page => page.posts).map(post => (
<Post key={post.id} post={post} />
))}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading more...' : 'Load More'}
</button>
)}
</>
);
}
Cache and Stale Time
Understanding staleTime and gcTime:
- staleTime: How long data is considered fresh. During this window, no background refetch. Default: 0 (always stale).
- gcTime (formerly cacheTime): How long unused data stays in cache. Default: 5 minutes.
useQuery({
queryKey: ['config'],
queryFn: fetchConfig,
staleTime: Infinity, // never refetch automatically
});
useQuery({
queryKey: ['feed'],
queryFn: fetchFeed,
staleTime: 30 * 1000, // 30 seconds fresh
refetchInterval: 60 * 1000, // refetch every 60 seconds when in view
});
TypeScript Integration
TanStack Query v5 is fully typed:
type Post = {
id: number;
title: string;
content: string;
};
const { data } = useQuery({
queryKey: ['post', id],
queryFn: (): Promise<Post> => fetch(`/api/posts/${id}`).then(r => r.json()),
});
// data is typed as Post | undefined
data?.title // ✓ TypeScript knows the shape
TanStack Query vs. SWR
Both solve the same problem. Key differences:
| Feature | TanStack Query | SWR |
|---|---|---|
| Mutations | Full support | Manual |
| Optimistic updates | Built-in helpers | Manual |
| Infinite queries | Built-in | useSWRInfinite |
| Devtools | Official | Third-party |
| Bundle size | ~13KB gzipped | ~4KB gzipped |
| Config options | Extensive | Minimal |
| Frameworks | React, Vue, Angular, Solid | React primarily |
SWR is simpler and smaller. TanStack Query has more features for complex data management. For most applications, TanStack Query's additional APIs (mutations, optimistic updates, devtools) pay for the extra weight.
TanStack Query v5 docs are at tanstack.com/query and the repository is TanStack/query.