TanStack Query turns server-state management from a pile of bespoke useEffect hooks into a declarative, cache-first system. If you're a full-stack developer tired of hand-rolling loading flags and race-condition guards, this guide shows you the minimal setup, the core concepts that matter, and the production pitfalls to avoid.
Here's the reality: TanStack Query (formerly React Query) is the single highest-leverage library I've added to a React codebase in the last three years. It doesn't just fetch data; it owns the entire lifecycle—caching, background refetching, retries, and stale-while-revalidate. But it isn't a silver bullet, so let's dig into when it shines and when you should leave it on the shelf.
Why TanStack Query Matters (and When to Skip It)
Most full-stack apps fail on the client because they treat server state like local state. You write a useEffect that calls fetch, stuff the result into useState, and pray. That approach breaks the moment you have two components needing the same data—you get duplicate requests, flickering spinners, and impossible-to-debug race conditions.
TanStack Query solves this by centralizing your server cache. It gives you isPending, isError, and data out of the box, handles request deduplication, and automatically invalidates stale data. I've cut boilerplate by roughly 60% on every project that adopts it.
When to skip it: If you're building a server-rendered app with zero client-side interactivity, or if your API calls are one-off events (like a login POST) with no shared data, you don't need it. A simple fetch wrapper suffices. But the moment you have a dashboard, a list/detail view, or any shared resource, TanStack Query pays for itself.
Getting Started with TanStack Query
The setup is embarrassingly simple. Install the package, wrap your app in a QueryClientProvider, and you're live.
npm install @tanstack/react-query
// main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
);
}
That's it. No config file, no middleware. The default settings are sane: retries on failure, garbage collection after 5 minutes, and stale data refetched on window focus. You can override these globally or per-query, but start with defaults and tune later.
Core TanStack Query Concepts Every Developer Should Know
1. Queries are keyed, not called
The queryKey is the heart of the cache. It must be unique and serializable—it's what TanStack Query uses to deduplicate and share data across components.
import { useQuery } from '@tanstack/react-query';
interface User {
id: number;
name: string;
}
async function fetchUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error('Failed to fetch user');
return res.json();
}
function UserProfile({ userId }: { userId: number }) {
const { data, isPending, isError } = useQuery({
queryKey: ['user', userId], // Include the param in the key
queryFn: () => fetchUser(userId),
});
if (isPending) return <div>Loading...</div>;
if (isError) return <div>Error loading user</div>;
return <div>{data.name}</div>;
}
Notice the key: ['user', userId]. If two components render with the same userId, they share one request. If userId changes, TanStack Query automatically fetches the new one.
2. Mutations handle writes and cache invalidation
Queries read; mutations write. A mutation runs your async function, then you tell TanStack Query which queries to invalidate so they refetch.
import { useMutation, useQueryClient } from '@tanstack/react-query';
function UpdateUserName() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newName: string) =>
fetch('/api/users/1', {
method: 'PATCH',
body: JSON.stringify({ name: newName }),
}),
onSuccess: () => {
// Refetch any query with the 'user' key
queryClient.invalidateQueries({ queryKey: ['user'] });
},
});
return (
<button onClick={() => mutation.mutate('New Name')}>
Update Name
</button>
);
}
The critical insight: invalidateQueries doesn't delete data—it marks it stale and triggers a background refetch. Your UI stays responsive, and the new data swaps in when ready.
3. Stale time prevents pointless requests
By default, data is considered stale immediately. TanStack Query will refetch on mount, window focus, and network reconnect. That's aggressive but correct for most apps. If you have data that rarely changes, set staleTime to save bandwidth.
// Fetch once, treat as fresh for 5 minutes
useQuery({
queryKey: ['config'],
queryFn: fetchConfig,
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
});
Common TanStack Query Mistakes and How to Fix Them
Mistake 1: Using the whole array as a query key
// BAD: Every render creates a new key
const filters = { status: 'active', page: 2 };
useQuery({ queryKey: [filters], queryFn: ... });
// GOOD: Stable, serializable key
useQuery({
queryKey: ['todos', filters.status, filters.page],
queryFn: ...,
});
Objects in query keys are compared by reference, not value. A new object literal breaks the cache. Flatten your keys.
Mistake 2: Ignoring enabled for dependent queries
// BAD: Fetches before we have the ID
const { data: user } = useQuery({ queryKey: ['user'], queryFn: fetchUser });
const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetchPosts(user.id), // crashes if user is undefined
});
// GOOD: Gate the query
const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetchPosts(user.id),
enabled: !!user?.id,
});
Mistake 3: Not using placeholderData for pagination
If you paginate, you don't want a full-screen spinner on every page change. Use placeholderData to keep the previous page visible while fetching the next.
useQuery({
queryKey: ['todos', page],
queryFn: () => fetchPage(page),
placeholderData: (prev) => prev, // Keep old data while loading
});
This gives you an instant, non-flashing UX—the old page renders until the new one arrives.
When Should You Use TanStack Query?
Use TanStack Query when your app has shared, asynchronous server state—meaning data that multiple components read, that changes over time, or that requires synchronization with the server. The sweet spot is any CRUD app, dashboard, or social feed. If you're fetching data on mount and storing it in local state, you're already a candidate.
Skip it for: one-off POST requests (like login), real-time websocket streams (use a dedicated socket library), or fully server-rendered pages with no client hydration.
TanStack Query in Production
1. Set global defaults for retries and stale time. In development, aggressive refetching is fine. In production, cap retries to 1–2 to avoid hammering a failing API, and set a sensible staleTime (30s–5min) based on your data volatility.
2. Use the devtools. @tanstack/react-query-devtools gives you a live view of the cache, query states, and mutation history. It's invaluable for debugging stale data or unexpected refetches.
3. Structure your query keys as a hierarchy. A common pattern is ['entity', 'list'] and ['entity', 'detail', id]. When you mutate a detail, invalidate both. This prevents stale lists showing deleted items.
Final takeaway: Start with the default config, flatten your query keys, and gate dependent queries with enabled—that single afternoon of setup will eliminate an entire class of data-fetching bugs from your codebase.