Week 8: TanStack Query — Queries & Caching

Week 2's useEffect-based fetch worked, but you had to hand-build loading state, error state, and a race-condition guard yourself — and none of it was shared if two components needed the same data. TanStack Query treats server data as a distinct kind of state with its own rules: cached, keyed, and kept fresh automatically.

Module 6 of 13 Week 8 of 20 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Explain why server state needs different tools than useState
  • Fetch, cache and share data across components with useQuery
  • Design query keys that correctly scope caching and invalidation

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

terminal
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):

main.tsx
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:

before — Week 2's version
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>;
}
after — with useQuery
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.

keys that scale with their inputs
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:

dependent queries
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.

controlling staleness
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" doesn't mean "hidden"

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

Hands-on

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:

  1. Write fetchProducts(category?: string) and fetchProductById(id: number) functions that simulate a network call (Promise + setTimeout, ~400ms) over an in-memory array, so you don't need a real backend.
  2. Set up QueryClient/QueryClientProvider at your app's root.
  3. Rebuild the products list page with useQuery, keyed on ['products', category], showing a loading state via isPending and an error state via isError.
  4. 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 inside fetchProductById — that the second visit doesn't trigger a new network call within the staleTime window.
  5. Set a 30-second staleTime on the detail query and explain, in a comment, why that's a reasonable choice for this specific data.
Hint

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?

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?

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']?

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?

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.