1. Why Server State Is Different
useState and useReducer manage state your component fully
owns and controls. Data from a server is fundamentally different: it's owned
somewhere else, can change without your app doing anything, might be needed by
several unrelated components at once, and can go stale the moment you fetch it.
Modeling it with useState + useEffect (Week 2) means
re-solving the same problems in every component: loading flags, error handling, race
conditions, and — if two components need the same data — either duplicated requests
or awkward lifting of state neither component really "owns."
TanStack Query is a dedicated cache for exactly this kind of state. You describe what to fetch and how to identify it; the library handles caching, deduplication, background refetching, and loading/error state consistently everywhere you use it.
2. Setting Up TanStack Query
npm install @tanstack/react-query
A QueryClient holds the entire cache; a QueryClientProvider
makes it available to every component via context (a preview of Week 10's Context API,
already in use here under the hood):
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
const queryClient = new QueryClient();
createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
3. useQuery
Recall Week 2's race-condition exercise — a UserProfile component
manually fetching a user, handling loading state, and guarding against stale
responses with an ignore flag. Here's the same component with
useQuery:
function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let ignore = false;
setIsLoading(true);
fetchUser(userId).then(data => {
if (!ignore) { setUser(data); setIsLoading(false); }
});
return () => { ignore = true; };
}, [userId]);
if (isLoading) return <p>Loading...</p>;
return <p>{user?.name}</p>;
}
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }: { userId: number }) {
const { data: user, isPending, isError, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
if (isPending) return <p>Loading...</p>;
if (isError) return <p>Error: {error.message}</p>;
return <p>{user.name}</p>;
}
queryFn is any function returning a promise — usually a
fetch call. queryKey is an array that both identifies
and caches this specific query — ['user', userId] means
"the user with this specific ID," so switching between two user IDs (like clicking
through a list) gets each user cached separately, and switching back re-shows cached
data instantly instead of re-fetching. The race condition from Week 2 simply can't
happen here: TanStack Query tracks which request is current internally and discards
stale responses automatically.
4. Query Keys & Dependent Queries
Query keys are the entire caching model — get them right and caching, refetching, and invalidation (next week) all work correctly; get them wrong and you'll see stale data or unnecessary refetches. The rule: include every value the query result depends on.
useQuery({ queryKey: ['products'], queryFn: fetchAllProducts });
useQuery({ queryKey: ['products', category], queryFn: () => fetchProducts(category) });
useQuery({
queryKey: ['products', { category, sortBy, page }],
queryFn: () => fetchProducts({ category, sortBy, page }),
});
Two components calling useQuery with the identical key share
the same cache entry and the same in-flight request — call
useQuery({'{'} queryKey: ['user', 5], ... {'}'}) from a header avatar and
a profile page at the same time, and TanStack Query fires exactly one network request,
satisfying both.
A dependent query is one that shouldn't run until another piece of
data is available — controlled with the enabled option:
function UserOrders({ userId }: { userId: number | null }) {
const { data: orders } = useQuery({
queryKey: ['orders', userId],
queryFn: () => fetchOrdersForUser(userId!),
enabled: userId !== null, // don't run until a real userId exists
});
// ...
}
5. Staleness & Automatic Refetching
By default, TanStack Query treats fetched data as stale immediately and refetches it in specific situations — most notably, when the browser window regains focus. This is deliberate: data can change on the server while a user is on another tab, and refetching on refocus keeps what they see current without any code from you.
useQuery({
queryKey: ['products'],
queryFn: fetchAllProducts,
staleTime: 60 * 1000, // treat data as fresh for 60 seconds -- skip refetch-on-focus during that window
});
staleTime is the single most impactful option to tune: 0 (the default)
means "always consider this stale, refetch aggressively," appropriate for data that
changes often or must always be current. A larger staleTime suits data
that rarely changes — a list of countries, a user's own profile — trading a small
chance of staleness for far fewer network requests.
Stale data is still shown immediately from cache while a background refetch runs — that's why switching back to a previously-visited tab feels instant even though a network request is happening behind the scenes. Users see cached data first, then a seamless update if the server's answer changed.
6. Hands-on Exercise
Rebuild Week 6's product catalog on TanStack Query
Replace the in-memory array from Week 6 with a real (simulated) API and TanStack Query's caching.
Requirements:
- Write
fetchProducts(category?: string)andfetchProductById(id: number)functions that simulate a network call (Promise+setTimeout, ~400ms) over an in-memory array, so you don't need a real backend. - Set up
QueryClient/QueryClientProviderat your app's root. - Rebuild the products list page with
useQuery, keyed on['products', category], showing a loading state viaisPendingand an error state viaisError. - Rebuild the product detail page with
useQuery, keyed on['product', productId]. Visit the same product twice (navigate away and back) and confirm — by temporarily logging insidefetchProductById— that the second visit doesn't trigger a new network call within thestaleTimewindow. - Set a 30-second
staleTimeon the detail query and explain, in a comment, why that's a reasonable choice for this specific data.
Install the React Query Devtools (@tanstack/react-query-devtools) — its panel shows every query's key, status and staleness live, which makes it far easier to confirm caching is actually behaving the way you expect than console-logging alone.
7. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why doesn't server data fit naturally into a plain useState the way local UI state does?
Why doesn't server data fit naturally into a plain useState the way local UI state does?
Server data is owned somewhere else and can change without your app doing anything, may be needed by multiple unrelated components simultaneously, and can go stale the instant it's fetched. useState models data a component fully owns and controls — none of those server-specific concerns (sharing, staleness, background updates) are things it was designed to handle.
Q2
If two components both call useQuery({'{'} queryKey: ['user', 7], ... {'}'}) at the same time, how many network requests fire?
If two components both call useQuery({'{'} queryKey: ['user', 7], ... {'}'}) at the same time, how many network requests fire?
One. Identical query keys share the same cache entry and the same in-flight request — TanStack Query deduplicates automatically, so both components receive the single result once it resolves instead of each triggering its own fetch.
Q3
Why does a query key like ['products', category] need category included, instead of just ['products']?
Why does a query key like ['products', category] need category included, instead of just ['products']?
The query key is also the cache key — two different categories produce two genuinely different result sets, so they need separate cache entries. Omitting category from the key would mean switching categories reuses the same cache slot, showing stale results from whichever category was fetched first.
Q4
What does a longer staleTime actually trade off?
What does a longer staleTime actually trade off?
Fewer network requests (and less refetch-on-focus churn) in exchange for a larger window in which the user might be looking at data that's changed on the server without the app knowing yet. It's the right tradeoff for data that rarely changes; for data that must always be current, a short or zero staleTime is worth the extra requests.