TanStack Query (formerly React Query) is a declarative server state management library for React applications. It replaces the traditional pattern of fetching data in useEffect, storing it in local state, and manually managing loading/error flags with a single useQuery hook that handles caching, background refetching, stale-while-revalidate semantics, pagination, and optimistic updates out of the box. The core insight is that server state and client state are fundamentally different: server state is remote, asynchronous, and shared — and managing it with tools designed for synchronous local state (like Redux or useState) creates unnecessary complexity.
In the merchant dashboard, TanStack Query manages all communication between the React frontend and the API layer. Search results, product data, user sessions, and configuration settings all flow through query hooks. This eliminates an entire class of bugs: stale data displayed after navigation, duplicate requests when components remount, race conditions between overlapping fetches, and memory leaks from subscriptions that outlive their components. The library’s devtools provide a real-time view of the query cache, making it possible to inspect cache entries, trigger refetches, and debug stale data issues during development.
The library is framework-agnostic at its core (TanStack Query supports React, Vue, Solid, Svelte, and Angular), but the React adapter is the most widely used. It integrates with React’s Suspense boundaries for loading states and ErrorBoundary for error handling, allowing data fetching concerns to be expressed declaratively in the component tree rather than imperatively in event handlers.
Every query is identified by a unique key — typically an array like ['products', { search: 'shoes' }]. TanStack Query uses these keys to deduplicate requests, cache responses, and invalidate data. If two components call useQuery with the same key, only one network request fires. The key is structurally compared (deep equality), so ['products', { page: 1 }] and ['products', { page: 2 }] are different cache entries. Key design is the most important architectural decision when adopting TanStack Query.
TanStack Query returns cached (potentially stale) data immediately while fetching fresh data in the background. The staleTime option controls how long data is considered fresh (default: 0, meaning data is immediately stale after fetch). The gcTime (garbage collection time, formerly cacheTime) controls how long inactive cache entries are kept in memory (default: 5 minutes). This pattern eliminates loading spinners for data the user has already seen, providing instant navigation between previously visited views.
After a mutation (POST, PUT, DELETE), the affected cache entries must be updated. queryClient.invalidateQueries({ queryKey: ['products'] }) marks all queries with keys starting with ['products'] as stale and triggers a background refetch for any that are currently rendered. This is more reliable than manually updating the cache because the server is the source of truth — the refetch picks up any side effects the mutation triggered (computed fields, updated timestamps, related records).
For mutations where the expected outcome is known (e.g., toggling a boolean, incrementing a counter), TanStack Query supports optimistic updates: update the cache immediately before the request completes, then roll back if the mutation fails. This is implemented via the onMutate, onError, and onSettled callbacks on useMutation. The onMutate handler snapshots the current cache, applies the optimistic change, and returns the snapshot. The onError handler restores the snapshot if the request fails.
TanStack Query refetches stale queries automatically on three triggers: when the component mounts (refetchOnMount), when the browser window regains focus (refetchOnWindowFocus), and when the network reconnects (refetchOnReconnect). These defaults ensure users always see fresh data without the application explicitly managing refresh logic. Each trigger can be configured per-query or globally.
The QueryClient holds the cache and configuration. It’s created once and provided to the component tree via QueryClientProvider. Default options set here apply to all queries unless overridden.
// app/providers.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // Data fresh for 1 minute
gcTime: 5 * 60 * 1000, // Cache kept for 5 minutes
retry: 2, // Retry failed requests twice
refetchOnWindowFocus: true, // Refetch when tab regains focus
},
},
});
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
The useQuery hook fetches data and manages the full lifecycle: loading, error, success, and background refetching states. The query function receives a context object with the query key and an AbortSignal for cancellation.
// hooks/useProducts.ts
import { useQuery } from '@tanstack/react-query';
interface Product {
id: string;
name: string;
price: number;
category: string;
}
async function fetchProducts(params: {
search?: string;
page: number;
signal: AbortSignal;
}): Promise<{ items: Product[]; total: number }> {
const url = new URL('/api/products', window.location.origin);
if (params.search) url.searchParams.set('q', params.search);
url.searchParams.set('page', String(params.page));
const res = await fetch(url, { signal: params.signal });
if (!res.ok) throw new Error(`Products fetch failed: ${res.status}`);
return res.json();
}
export function useProducts(search: string, page: number) {
return useQuery({
// Key changes when search or page changes → automatic refetch
queryKey: ['products', { search, page }],
queryFn: ({ signal }) => fetchProducts({ search, page, signal }),
// Keep previous data visible while fetching the next page
placeholderData: (previousData) => previousData,
});
}
// Usage in a component:
function ProductList() {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const { data, isLoading, isError, error, isFetching } = useProducts(search, page);
if (isLoading) return <Spinner />;
if (isError) return <ErrorMessage error={error} />;
return (
<div>
{isFetching && <RefetchIndicator />}
{data.items.map(p => <ProductCard key={p.id} product={p} />)}
<Pagination page={page} total={data.total} onPageChange={setPage} />
</div>
);
}
Mutations modify server state and update the cache. The optimistic update pattern provides instant feedback while the request is in flight.
// hooks/useToggleFavorite.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
export function useToggleFavorite() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (productId: string) => {
const res = await fetch(`/api/products/${productId}/favorite`, {
method: 'POST',
});
if (!res.ok) throw new Error('Failed to toggle favorite');
return res.json();
},
// Optimistic update: toggle immediately in the cache
onMutate: async (productId) => {
// Cancel outgoing refetches so they don't overwrite our optimistic update
await queryClient.cancelQueries({ queryKey: ['products'] });
// Snapshot current cache for rollback
const previousProducts = queryClient.getQueriesData({ queryKey: ['products'] });
// Optimistically update all product queries that contain this product
queryClient.setQueriesData(
{ queryKey: ['products'] },
(old: any) => {
if (!old?.items) return old;
return {
...old,
items: old.items.map((p: any) =>
p.id === productId ? { ...p, isFavorite: !p.isFavorite } : p
),
};
}
);
return { previousProducts };
},
// Rollback on error
onError: (_err, _productId, context) => {
context?.previousProducts.forEach(([key, data]) => {
queryClient.setQueryData(key, data);
});
},
// Refetch after success or error to ensure consistency
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
}
In the merchant dashboard, TanStack Query is the backbone of the data layer. Every API call from the React frontend goes through a query or mutation hook. Search results use useQuery with the search term and filters as the query key, so changing a filter automatically triggers a new fetch while the previous results remain visible. Product detail pages prefetch data on hover using queryClient.prefetchQuery, so by the time the user clicks, the data is already cached and the page renders instantly.
The admin dashboard uses infinite queries (useInfiniteQuery) for paginated log views and activity feeds. This hook manages an array of pages, fetches the next page when the user scrolls to the bottom, and concatenates results automatically. Combined with a virtualized list, this provides smooth scrolling through thousands of log entries without loading all of them upfront.
Configuration settings are fetched once with a long staleTime (10 minutes) since they rarely change. When an admin updates a setting, the mutation’s onSettled callback invalidates the configuration query, and all components displaying that setting update within milliseconds. This eliminates the need for a global state management library for server-derived state — TanStack Query’s cache is the global store.
As applications grow, query keys proliferate. A query key factory centralizes key construction, preventing typos and making invalidation patterns explicit. The factory returns progressively more specific keys: productKeys.all invalidates everything, productKeys.lists() invalidates all list queries, and productKeys.detail(id) targets a single product.
When one query depends on the result of another (e.g., fetch user, then fetch their organization), use the enabled option: useQuery({ queryKey: ['org', userId], queryFn: ..., enabled: !!userId }). The second query won’t fire until userId is available. This replaces the waterfall of useEffect calls that typically handle sequential data dependencies.
TanStack Query integrates with React Suspense via useSuspenseQuery. Instead of checking isLoading in every component, wrap a subtree in a <Suspense fallback={<Spinner />}> boundary. The query hook throws a promise that Suspense catches, rendering the fallback until data arrives. This moves loading state management out of individual components and into the layout layer.
QueryClient is instantiated inside a component body (not in a useRef or module scope), every re-render creates a new client, destroying the cache. Always create the client outside the component or use useState/useRef to persist it.staleTime: 0, every component mount triggers a background refetch, even if the data was fetched 100ms ago. For data that changes infrequently (configuration, user profile), set staleTime to minutes or hours to avoid unnecessary requests.onSettled doesn’t call invalidateQueries, the cache may permanently hold stale data. Always invalidate as a safety net, even when doing optimistic updates.useQuery({ queryKey: ['products', { filters: getFilters() }] }) creates a new object reference every render, but TanStack Query uses deep comparison, so this is actually fine. The real pitfall is putting non-serializable values (functions, class instances) in query keys — these break the structural comparison.signal in the query function context. If you don’t forward it to fetch, cancelled queries (from unmounted components or changed keys) leave orphaned requests that waste bandwidth and can cause state update warnings on unmounted components.