Fetching data in React has always been awkward. You write a useEffect, manage loading and error states manually, forget to cancel stale requests, and end up with components that are 60% data-fetching boilerplate. Worse, you store server responses in global state managers like Redux, treating remote data the same as a UI toggle. TanStack Query (formerly React Query) draws a hard line between server state and client state, and handles the former with automatic caching, background refetching, and stale-while-revalidate semantics that would take hundreds of lines to replicate by hand.
Queries: fetching data without the ceremony
A query is defined by a unique key and an async function that returns data. TanStack Query handles everything else: loading states, error states, caching, deduplication of concurrent requests, and automatic garbage collection when the data is no longer needed.
import { useQuery } from '@tanstack/react-query';
interface Product {
id: string;
name: string;
price: number;
}
export function useProducts() {
return useQuery({
queryKey: ['products'],
queryFn: async (): Promise<Product[]> => {
const res = await fetch('/api/products');
if (!res.ok) throw new Error('Failed to fetch products');
return res.json();
},
});
}
function ProductList() {
const { data, isLoading, error } = useProducts();
if (isLoading) return <Skeleton />;
if (error) return <ErrorMessage error={error} />;
return (
<ul>
{data.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
If two components mount at the same time and both call useProducts, only one network request fires. Both components receive the same cached data. When the user navigates away and comes back, TanStack Query serves the stale cache instantly while refetching in the background.
Query keys: the cache identity system
Query keys are arrays that uniquely identify a piece of server data. They determine when cached data is reused and when a new fetch is triggered.
export function useProduct(id: string) {
return useQuery({
queryKey: ['products', id],
queryFn: () => fetchProduct(id),
});
}
export function useFilteredProducts(filters: ProductFilters) {
return useQuery({
queryKey: ['products', 'list', filters],
queryFn: () => fetchProducts(filters),
});
}
Changing the id or filters object produces a new cache entry. Previous entries stay in cache for the configured garbage collection time, so navigating back to a previously viewed product is instantaneous.
Mutations: writing data and keeping the cache in sync
Mutations handle create, update, and delete operations. The real power comes from onSuccess callbacks that invalidate related queries, keeping the UI in sync without manual refetching logic scattered across components.
import { useMutation, useQueryClient } from '@tanstack/react-query';
export function useCreateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (newProduct: CreateProductInput) => {
const res = await fetch('/api/products', {
method: 'POST',
body: JSON.stringify(newProduct),
});
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
}
Calling invalidateQueries marks all queries starting with ['products'] as stale. Any mounted component consuming that data refetches automatically. No event bus, no manual store updates, no dispatching actions to synchronize state.
Optimistic updates for snappy UIs
When the user expects immediate feedback, waiting for the server round-trip feels sluggish. Optimistic updates apply the change to the cache before the mutation completes, then roll back if it fails.
export function useToggleFavorite() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (productId: string) => api.toggleFavorite(productId),
onMutate: async (productId) => {
await queryClient.cancelQueries({ queryKey: ['products', productId] });
const previous = queryClient.getQueryData<Product>(['products', productId]);
queryClient.setQueryData<Product>(['products', productId], (old) =>
old ? { ...old, isFavorite: !old.isFavorite } : old
);
return { previous };
},
onError: (_err, productId, context) => {
queryClient.setQueryData(['products', productId], context?.previous);
},
onSettled: (_data, _err, productId) => {
queryClient.invalidateQueries({ queryKey: ['products', productId] });
},
});
}
The onMutate callback snapshots the current data, applies the optimistic change, and returns the snapshot. If the mutation fails, onError restores it. onSettled always refetches to ensure the cache matches the server, whether the mutation succeeded or not.
Parallel and dependent queries
Independent data sources should be fetched in parallel. TanStack Query makes this the default when you use multiple useQuery hooks in the same component.
function Dashboard() {
const products = useQuery({ queryKey: ['products'], queryFn: fetchProducts });
const analytics = useQuery({ queryKey: ['analytics'], queryFn: fetchAnalytics });
const notifications = useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
});
// All three requests fire simultaneously
}
For dependent queries where one fetch needs data from another, the enabled option prevents execution until the dependency is ready.
function useUserOrders(userId: string | undefined) {
return useQuery({
queryKey: ['orders', userId],
queryFn: () => fetchOrders(userId!),
enabled: !!userId,
});
}
No waterfall, no manual orchestration. The query waits for userId to exist, then fetches.
Infinite queries for paginated data
Infinite scroll and “load more” patterns are first-class citizens. useInfiniteQuery manages pages of data as a single cache entry, with built-in support for fetching the next page.
import { useInfiniteQuery } from '@tanstack/react-query';
export function useFeed() {
return useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) => fetchFeed(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});
}
function Feed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useFeed();
return (
<>
{data?.pages.flatMap((page) =>
page.items.map((item) => <FeedItem key={item.id} item={item} />)
)}
{hasNextPage ? (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
Load more
</button>
) : null}
</>
);
}
Each page is cached independently, so navigating away and back doesn’t re-fetch every page from scratch.
Conclusion
TanStack Query reframes how we think about data in React. Server state isn’t something to shove into a global store. It’s a cache that needs to stay fresh, handle concurrent access gracefully, and degrade well on slow networks. The library handles all of this with a declarative API that reduces data-fetching code by an order of magnitude. If your React app talks to an API, TanStack Query should be the first dependency you reach for.