TanStack Query for Server State
The Server State Problem
Here's a question that trips up even experienced developers: is the user list you fetched from your API "state" in the same way a sidebar toggle is "state"?
No. Not even close. That user list has properties no client state ever will:
- It can become stale — someone else might have changed it on the server
- It has a remote owner — you don't control the source of truth
- It needs deduplication — three components requesting the same data shouldn't trigger three network requests
- It needs background refresh — when the user refocuses the tab, it should silently update
- It needs garbage collection — unused cache entries should eventually be freed
When you put server data into Redux, you're signing up to build all of this yourself. TanStack Query (formerly React Query) gives you all of it — plus optimistic updates, infinite scroll, prefetching, and DevTools — with a fraction of the code.
Think of TanStack Query as a smart cache layer between your components and your API. Every query is identified by a unique key. When a component asks for data, the cache checks: do I have it? Is it fresh? If fresh, serve instantly. If stale, serve the cached version immediately AND fetch a fresh copy in the background. If missing, fetch and cache. Multiple components asking for the same key share the same cache entry and the same in-flight request. It's the HTTP caching model applied to React state.
Query Keys: The Foundation
Every piece of server state in TanStack Query is identified by a query key — an array that uniquely describes the data. Query keys determine cache identity: same key = same cache entry.
// Simple key
useQuery({ queryKey: ['users'], queryFn: fetchUsers });
// Parameterized key — different userId = different cache entry
useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId) });
// Complex key with filters
useQuery({
queryKey: ['products', { category, sort, page }],
queryFn: () => fetchProducts({ category, sort, page }),
});
The key insight: query keys are serialized deterministically. ['products', { sort: 'price', category: 'electronics' }] and ['products', { category: 'electronics', sort: 'price' }] produce the same cache entry — object key order doesn't matter.
Stale Time vs GC Time
These two settings control TanStack Query's caching behavior, and confusing them is one of the most common mistakes.
useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60_000, // 5 minutes — data is "fresh" for this long
gcTime: 30 * 60_000, // 30 minutes — unused cache is garbage collected after this
});
The key mental model: staleTime controls when a background refetch is triggered. gcTime controls when unused cache entries are removed from memory. They serve completely different purposes.
Background Refetching
TanStack Query automatically refetches stale data on three triggers:
- Window focus — user switches back to the tab (configurable via
refetchOnWindowFocus) - Component remount — a component using the query mounts (configurable via
refetchOnMount) - Network reconnect — the device goes back online (configurable via
refetchOnReconnect)
useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
staleTime: 30_000,
refetchInterval: 60_000, // poll every 60 seconds
refetchIntervalInBackground: false, // stop polling when tab is hidden
});
The genius of this model: your components always show the latest cached data instantly, and fresh data arrives in the background without loading spinners. The user sees data immediately and never waits for a refetch.
A common trap: setting staleTime: 0 (the default) causes a refetch on every mount. If your component mounts and unmounts frequently (like in a tab interface), you'll fire a network request every time the user switches tabs. Set a reasonable staleTime based on how frequently your data actually changes. For user profiles, 5 minutes is usually fine. For stock prices, 5 seconds.
Query Deduplication
When multiple components use the same query key, TanStack Query deduplicates the request automatically:
function UserAvatar() {
const { data: user } = useQuery({ queryKey: ['user', userId], queryFn: fetchUser });
return <img src={user?.avatar} />;
}
function UserName() {
const { data: user } = useQuery({ queryKey: ['user', userId], queryFn: fetchUser });
return <span>{user?.name}</span>;
}
function UserBadge() {
const { data: user } = useQuery({ queryKey: ['user', userId], queryFn: fetchUser });
return <div>{user?.role}</div>;
}
Three components, one network request. All three subscribe to the same cache entry. When the data updates, all three re-render with the new data. This is what TanStack Query does that useEffect + fetch can't — shared subscriptions to a centralized cache.
Optimistic Updates
The traditional approach: user clicks "like", show a spinner, wait for the server, update the UI. The optimistic approach: update the UI immediately, send the request, roll back if it fails.
const queryClient = useQueryClient();
const likeMutation = useMutation({
mutationFn: (postId: string) => likePost(postId),
onMutate: async (postId) => {
await queryClient.cancelQueries({ queryKey: ['post', postId] });
const previous = queryClient.getQueryData(['post', postId]);
queryClient.setQueryData(['post', postId], (old: Post) => ({
...old,
likes: old.likes + 1,
isLiked: true,
}));
return { previous };
},
onError: (_err, postId, context) => {
queryClient.setQueryData(['post', postId], context?.previous);
},
onSettled: (_data, _err, postId) => {
queryClient.invalidateQueries({ queryKey: ['post', postId] });
},
});
The pattern: onMutate saves the old data and optimistically updates the cache. onError rolls back to the saved snapshot. onSettled invalidates the query to refetch the true server state regardless of success or failure.
Infinite Queries
For paginated lists that load more content as the user scrolls:
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['products', category],
queryFn: ({ pageParam }) => fetchProducts({ category, cursor: pageParam }),
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});
const allProducts = data?.pages.flatMap((page) => page.items) ?? [];
TanStack Query v5 requires initialPageParam to be explicitly set — this prevents caching bugs where the first page parameter was undefined.
Query Invalidation
When a mutation succeeds, you often need to invalidate related queries so they refetch:
const addProduct = useMutation({
mutationFn: createProduct,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
Invalidation is hierarchical. invalidateQueries({ queryKey: ['products'] }) invalidates:
['products']['products', { category: 'electronics' }]['products', { category: 'clothing', sort: 'price' }]
Any query key that starts with ['products'] gets invalidated. This is incredibly powerful for cascading cache updates.
Exact vs fuzzy invalidation
By default, invalidation is fuzzy — it matches any query key that starts with the provided key. Pass exact: true to invalidate only the exact key:
queryClient.invalidateQueries({ queryKey: ['products'], exact: true });This only invalidates ['products'], not ['products', { category: 'electronics' }]. Use exact invalidation when you want surgical precision.
Prefetching
Load data before the user needs it. Hover over a link? Prefetch that page's data. About to show step 2 of a wizard? Prefetch step 2's data during step 1.
const queryClient = useQueryClient();
function ProductCard({ productId }: { productId: string }) {
const prefetchProduct = () => {
queryClient.prefetchQuery({
queryKey: ['product', productId],
queryFn: () => fetchProduct(productId),
staleTime: 60_000,
});
};
return (
<Link
href={`/products/${productId}`}
onMouseEnter={prefetchProduct}
onFocus={prefetchProduct}
>
{/* ... */}
</Link>
);
}
When the user actually navigates, the data is already in the cache. Zero loading time. This is the kind of perceived performance improvement that separates great apps from good ones.
Suspense Integration
TanStack Query v5 provides dedicated Suspense hooks:
import { useSuspenseQuery } from '@tanstack/react-query';
function UserProfile({ userId }: { userId: string }) {
const { data: user } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
return <div>{user.name}</div>;
}
With useSuspenseQuery, the data is guaranteed to be defined (no undefined checks needed). The component suspends until data is available, and you handle loading states with a Suspense boundary higher in the tree.
DevTools
TanStack Query DevTools give you a real-time view of every cache entry, its state (fresh, stale, fetching, inactive), and the ability to manually invalidate or refetch queries.
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
function App() {
return (
<QueryClientProvider client={queryClient}>
<AppContent />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
The DevTools are tree-shaken out of production builds automatically.
When TanStack Query Replaces Redux
Here's the thing most teams discover: once they move server state to TanStack Query, their Redux store shrinks by 70-90%. What's left is a handful of UI toggles and theme preferences — state so simple it can live in useState or a tiny Zustand store.
| What developers do | What they should do |
|---|---|
| Using useEffect + useState to fetch data and manage loading/error states manually Manual fetching in useEffect is missing cache deduplication, background refetching, retry logic, garbage collection, and race condition handling. You'll rebuild half of TanStack Query badly. | Use useQuery which handles loading, error, caching, deduplication, refetching, and retry automatically |
| Setting staleTime to 0 (default) for data that rarely changes staleTime: 0 means every mount triggers a background refetch. For data that changes once a day, you're making thousands of unnecessary requests. | Set staleTime based on how frequently your data actually changes. User profiles: 5 min. Config: 30 min. Real-time data: 0. |
| Using queryClient.setQueryData everywhere instead of invalidateQueries after mutations Manual cache updates are fragile — you must replicate server-side logic perfectly. Invalidation lets the server remain the source of truth. | Prefer invalidateQueries to refetch from the server. Use setQueryData only for optimistic updates with rollback. |
- 1Query keys are the identity of your cache. Be consistent with types (number vs string) and structure.
- 2staleTime = how long data is considered fresh. gcTime = how long unused cache entries survive. They are independent.
- 3Background refetching serves stale data instantly while fetching fresh data — no loading spinners for cached data.
- 4Deduplication is automatic: same query key = same request, no matter how many components subscribe.
- 5Invalidation is hierarchical: invalidating ['products'] also invalidates ['products', { category: 'x' }].
- 6For optimistic updates: save previous state in onMutate, roll back in onError, invalidate in onSettled.