Module 5: Data Fetching With React Query

Mini-project: Mercado's products with React Query

Overview

The moment has come to bring the whole module together in a single piece. You're going to manage Mercado's products end to end with React Query, with the three parts you already know how to build: the classification table that places each Mercado data in its tool (with the products finally in their box: React Query), the real React code (the QueryClientProvider, the catalog's useQuery and the search's), and a session executed in Node that combines dedup, stale-while-revalidate, queryKey per search and the states, plus a scorecard that measures the before (M4's manual fetching) against the after (React Query). This mini-project is the synthesis: it brings together useQuery, the queryKey as identity, the cache/dedup, the stale-while-revalidate and the states, and closes the reading side of server state.

Connection with the module. It's the capstone. It brings together the seven lessons: useQuery's declarative model (L2), the queryKey as identity (L3), the cache and the dedup (L4), the stale-while-revalidate (L5), the states (L6) and the application to Mercado (L7). And it closes the guide's arc up to here: server state (the products) is resolved with React Query; what changes often (the cart) with a store (M3); what changes little (theme, user) with Context (M2); and the URL one (search/filters) with searchParams (M7). With this you finish the reading of server state and are ready for module 6, where you learn to write it (mutations and invalidation).

The plan: what we're going to build

The product fetching has three design rules that come out of the module:

  1. Classify before choosing. Each piece of Mercado's state goes in its tool (module 1). The products are server state —a cached copy of backend data— → React Query. Not Context, not store, not useState.
  2. Declare data by their queryKey, don't orchestrate fetches. The catalog is ['products'] (one copy, shared by all the components that show it); a search is ['products', { query }] (one entry per term, with the query in the key). You choose how long they stay fresh (staleTime) and the layer does the rest —dedup, revalidation, states—.
  3. What isn't the server's, out of React Query. The cart doesn't go here (changes often → store, M3); the theme and the user don't either (change little → Context, M2); the search as text doesn't either (goes in the URL → M7); an open menu doesn't either (it's local → useState).

The state classification table

Before a line of code, the decision made explicit. This is the picture of Mercado's state spread across its tools, with the products finally in their place:

State piece            What it is                         Tool
─────────────────────  ────────────────────────────────  ─────────────────────────────
products               server (remote cache)              React Query (this module)
search / filters       URL                                searchParams (M7)
cart (items)           global client, changes OFTEN       store + selectors (M3)
theme / user           global client, changes little      Context (M2)
dropdown / open menu   local to a component               useState (M1)

Read it as the summary of where you are in the guide: the products finally have their tool (this module); the cart already had it (store, M3); the theme and the user too (Context, M2); and one box remains to resolve —the search/filters in the URL (M7)—. This project builds the first row and leaves the others in their place.

The tree and the data flow

This is the storefront with the products managed by React Query, in a cache outside the tree, queried by queryKey. The cart, the theme and the search appear aside, pointing to their tool:

flowchart TD
    QC["queryClient  (the cache, outside the tree: one copy per queryKey)"]
    App[App]
    App --> Header
    Header --> Search["SearchBar  (the text -> URL, M7)"]
    App --> PL["ProductList  useQuery(['products'])"]
    App --> FG["FeaturedGrid  useQuery(['products'])"]
    App --> SR["SearchResults  useQuery(['products', { query }])"]
    App --> Cart["Cart  (store, M3)"]

    PL -. "['products']" .-> QC
    FG -. "['products']" .-> QC
    SR -. "['products', { query }]" .-> QC
    QC -. "1 fetch per queryKey" .-> Backend[("Backend")]

Read it with what you've learned. The queryClient is the central cache, outside the tree, and the product components connect with dotted lines by their queryKey: ProductList and FeaturedGrid share ['products'] (one copy, one fetch), and SearchResults uses ['products', { query }] (its own entry per search). The Cart doesn't touch the queryClient —it lives in the store (M3)— and the search text goes to the URL (M7). Each box, its tool; the products, in React Query.

The real React code

This is Mercado's fetching as you'd write it with React Query. Read it piece by piece, noting where each lesson lands.

The Provider — the cache, once, outside the tree (L2, L4).

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient({
  defaultOptions: { queries: { staleTime: 60_000 } },   // the catalog is considered fresh for 60s
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>   {/* the cache, outside the tree, for the whole app */}
      <Header />          {/* SearchBar */}
      <ProductList />     {/* useQuery(['products']) */}
      <FeaturedGrid />    {/* useQuery(['products']) -> shares the copy */}
      <SearchResults />   {/* useQuery(['products', { query }]) */}
    </QueryClientProvider>
  );
}

The queryClient is created once, outside the tree (L4: a global cache, not one per component), and the QueryClientProvider makes it accessible to all the useQuery. The staleTime: 60_000 by default applies the freshness policy to all the queries (L5).

The catalog — shared declaration (L2, L4, L6).

import { useQuery } from '@tanstack/react-query';

function ProductList() {
  const { data, isLoading, isError, isFetching } = useQuery({
    queryKey: ['products'],       // the catalog's identity
    queryFn: fetchProducts,       // how to request it
  });
  if (isLoading) return <Spinner />;          // first load (L6)
  if (isError)   return <ErrorBox retry />;   // failure (L6): handle before reading data
  return (
    <div>
      {isFetching && <RefreshDot />}          {/* background revalidation (L5, L6) */}
      <ul>{data.map((p) => <ProductCard key={p.id} product={p} />)}</ul>
    </div>
  );
}
// FeaturedGrid declares the SAME ['products'] -> shares copy and fetch (dedup, L4).

ProductList declares ['products'] and reads the states in the canonical order (isLoadingisErrordata, L6). FeaturedGrid, with the same key, shares the copy and the fetch (dedup, L4). Neither orchestrates anything: they declare the data.

The search — the query in the key (L3, L7).

function SearchResults({ query }) {
  const { data, isFetching } = useQuery({
    queryKey: ['products', { query }],        // the term, in the key (L3): one entry per search
    queryFn: () => searchProducts(query),
    enabled: query.length > 0,                 // doesn't search with an empty input (L7)
  });
  if (query.length === 0) return null;
  return (
    <div>
      {isFetching && <RefreshDot />}
      <ul>{data?.map((p) => <ProductCard key={p.id} product={p} />)}</ul>
    </div>
  );
}

SearchResults puts the query in the queryKey (L3), so each term has its entry and returning to a seen one is a cache hit (L7). The enabled avoids searching with an empty input. The formatPrice(2599)"$25.99" is the usual one, inside ProductCard.

Worked example: a session, executed

We're going to run Mercado's fetching: first the classification table, then the scorecard that measures the same scenario with manual fetching (M4) and with React Query (M5), and finally a complete session —catalog (dedup), searches (one entry per term, cache hit on returning) and revalidation (swr)—:

const backend = {
  calls: 0,
  _products: [
    { id: 'p1', name: 'Wireless Mouse', priceCents: 2599 },
    { id: 'p2', name: 'Mechanical Keyboard', priceCents: 8900 },
    { id: 'p3', name: 'Mouse Pad', priceCents: 1200 },
  ],
  setMousePrice(c) { this._products[0].priceCents = c; },
  fetchProducts() { this.calls++; return this._products.map((p) => ({ ...p })); },
  search(q) { this.calls++; return this._products.filter((p) => p.name.toLowerCase().includes(q)); },
};
const priceOf = (list, id) => '$' + (list.find((p) => p.id === id).priceCents / 100).toFixed(2);
const names = (list) => list.map((p) => p.name).join(', ');

const clock = { now: 0 };
function createQueryClient() {
  const cache = new Map();
  const inflight = new Set();
  const queue = [];
  const hash = (key) => JSON.stringify(key);

  function useQuery(queryKey, queryFn, { staleTime = 0 } = {}) {
    const h = hash(queryKey);
    const entry = cache.get(h);
    const hasData = !!entry && entry.status === 'success';
    const errored = !!entry && entry.status === 'error';
    const isStale = !entry || clock.now - entry.updatedAt >= staleTime;
    if ((!hasData || isStale) && !errored && !inflight.has(h)) {
      inflight.add(h);
      queue.push({ h, queryFn });
    }
    return {
      data: entry ? entry.data : undefined,
      isLoading: !hasData && inflight.has(h),
      isFetching: inflight.has(h),
      isError: errored,
    };
  }

  function flush() {
    const batch = queue.splice(0);
    batch.forEach(({ h, queryFn }) => {
      try { cache.set(h, { data: queryFn(), updatedAt: clock.now, status: 'success' }); }
      catch (err) { const prev = cache.get(h) || {}; cache.set(h, { data: prev.data, updatedAt: clock.now, status: 'error' }); }
      inflight.delete(h);
    });
    return batch.length;
  }

  return { useQuery, flush, cache, hash };
}

const FRESH = { staleTime: 60000 };

// ---- 1) The classification table of Mercado state -----------------
console.log('=== 1) Mercado state classification ===\n');
const stateMap = [
  ['products',          'SERVER state (cache)',      'React Query (this module)'],
  ['search / filters',  'URL state',                 'searchParams (M7)'],
  ['cart (items)',      'global client, changes OFTEN', 'store + selectors (M3)'],
  ['theme / user',      'global client, changes little', 'Context (M2)'],
  ['open dropdown',     'local to a component',      'useState (M1)'],
];
console.log('  Piece               | What it is                          | Tool');
console.log('  --------------------|-------------------------------------|---------------------------');
stateMap.forEach(([p, w, t]) => console.log('  ' + p.padEnd(19) + ' | ' + w.padEnd(35) + ' | ' + t));

// ---- 2) Scorecard: the same scenario, manual (M4) vs React Query (M5) ---
console.log('\n=== 2) Scorecard: manual fetching (M4) vs React Query (M5) ===\n');

// MANUAL: 3 components, each its fetch; a copy that never revalidates.
backend.setMousePrice(2599);
backend.calls = 0;
const copies = [backend.fetchProducts(), backend.fetchProducts(), backend.fetchProducts()]; // 3 useEffect
const manualFetches = backend.calls;
backend.setMousePrice(1999);                    // the admin lowers the price in the backend
const manualPrice = priceOf(copies[0], 'p1');   // the manual copy never asks again

// REACT QUERY: 3 components declare the same query; dedup + revalidation.
backend.setMousePrice(2599);
const qc = createQueryClient();
backend.calls = 0; clock.now = 0;
qc.useQuery(['products'], () => backend.fetchProducts(), FRESH);
qc.useQuery(['products'], () => backend.fetchProducts(), FRESH);
qc.useQuery(['products'], () => backend.fetchProducts(), FRESH);
qc.flush();
const rqFetches = backend.calls;
backend.setMousePrice(1999);                    // same backend change
clock.now = 61000;                              // staleTime passes -> revalidates
qc.useQuery(['products'], () => backend.fetchProducts(), FRESH); // render 1: cache + background
qc.flush();                                     // the revalidation arrives
const rqPrice = priceOf(qc.cache.get(qc.hash(['products'])).data, 'p1');

const score = [
  ['fetches for 3 components',     manualFetches + '', rqFetches + ' (dedup)'],
  ['copy after backend change',    manualPrice + ' (stale)', rqPrice + ' (revalidates)'],
  ['loading/error state',          'repeated per component', 'one per queryKey'],
  ['data identity',                'none', 'queryKey'],
];
console.log('  Requirement                    | Manual (M4)              | React Query (M5)');
console.log('  -------------------------------|--------------------------|--------------------');
score.forEach(([r, m, rq]) => console.log('  ' + r.padEnd(30) + ' | ' + m.padEnd(24) + ' | ' + rq));

// ---- 3) Complete session: catalog + search + revalidation -------------
console.log('\n=== 3) Mercado session with React Query, executed ===\n');
const app = createQueryClient();
backend.setMousePrice(2599);
backend.calls = 0; clock.now = 0;

console.log('a) The catalog (dedup): ProductList + FeaturedGrid + SearchResults');
app.useQuery(['products'], () => backend.fetchProducts(), FRESH);
app.useQuery(['products'], () => backend.fetchProducts(), FRESH);
app.useQuery(['products'], () => backend.fetchProducts(), FRESH);
app.flush();
console.log('   fetches -> ' + backend.calls + ' | products -> ' + app.cache.get(app.hash(['products'])).data.length);

console.log('\nb) The search (queryKey with the query):');
backend.calls = 0;
for (const query of ['mouse', 'keyboard', 'mouse']) {
  const before = backend.calls;
  const r = app.useQuery(['products', { query }], () => backend.search(query), FRESH);
  app.flush();
  const hit = backend.calls === before ? 'CACHE HIT' : 'fetch';
  const data = app.cache.get(app.hash(['products', { query }])).data;
  console.log('   search ' + ('"' + query + '"').padEnd(10) + ' -> ' + hit.padEnd(9) + ' -> ' + names(data));
}
console.log('   cache entries -> ' + app.cache.size + ' | search fetches -> ' + backend.calls);

console.log('\nc) Catalog revalidation (swr) after a sale:');
backend.calls = 0;
backend.setMousePrice(1999);
clock.now = 61000;
const s1 = app.useQuery(['products'], () => backend.fetchProducts(), FRESH);
console.log('   render 1: Mouse = ' + priceOf(s1.data, 'p1') + ' (cache) | isFetching=' + s1.isFetching);
app.flush();
const s2 = app.useQuery(['products'], () => backend.fetchProducts(), FRESH);
console.log('   render 2: Mouse = ' + priceOf(s2.data, 'p1') + ' (fresh) | isFetching=' + s2.isFetching);
console.log('   fetches in the revalidation -> ' + backend.calls);

console.log('\nEach box in its tool; the products, server\'s, in React Query.');

What to expect. When you run the file with Node, the output is exactly this:

=== 1) Mercado state classification ===

  Piece               | What it is                          | Tool
  --------------------|-------------------------------------|---------------------------
  products            | SERVER state (cache)                | React Query (this module)
  search / filters    | URL state                           | searchParams (M7)
  cart (items)        | global client, changes OFTEN        | store + selectors (M3)
  theme / user        | global client, changes little       | Context (M2)
  open dropdown       | local to a component                | useState (M1)

=== 2) Scorecard: manual fetching (M4) vs React Query (M5) ===

  Requirement                    | Manual (M4)              | React Query (M5)
  -------------------------------|--------------------------|--------------------
  fetches for 3 components       | 3                        | 1 (dedup)
  copy after backend change      | $25.99 (stale)           | $19.99 (revalidates)
  loading/error state            | repeated per component   | one per queryKey
  data identity                  | none                     | queryKey

=== 3) Mercado session with React Query, executed ===

a) The catalog (dedup): ProductList + FeaturedGrid + SearchResults
   fetches -> 1 | products -> 3

b) The search (queryKey with the query):
   search "mouse"    -> fetch     -> Wireless Mouse, Mouse Pad
   search "keyboard" -> fetch     -> Mechanical Keyboard
   search "mouse"    -> CACHE HIT -> Wireless Mouse, Mouse Pad
   cache entries -> 3 | search fetches -> 2

c) Catalog revalidation (swr) after a sale:
   render 1: Mouse = $25.99 (cache) | isFetching=true
   render 2: Mouse = $19.99 (fresh) | isFetching=false
   fetches in the revalidation -> 1

Each box in its tool; the products, server's, in React Query.

Read the output from start to finish, because in it is the whole module working together.

The classification table opens the project with the decision made explicit: the products go in React Query (server), the search in the URL (M7), the cart in a store (M3), the theme and the user in Context (M2), an open menu in useState. Each piece, its tool. It's the guide's thesis —classify before choosing— applied to Mercado, with server state finally in its correct box.

The scorecard measures the before against the after in the same scenario. fetches for 3 components: manual 3, React Query 1 (dedup). copy after backend change: manual $25.99 (stale: never revalidates), React Query $19.99 (revalidates with stale-while-revalidate). loading/error state: manual repeated in each component (which can diverge), React Query one per queryKey (coherent). data identity: manual none (each useEffect is an island), React Query the queryKey (the coordinator). Four requirements, four victories —and they all come from the same idea: one copy per key, managed by the layer—.

The session executes the complete pattern. In (a), the catalog: three components → 1 fetch (dedup), the three show the 3 products. In (b), the search: "mouse" and "keyboard" do a fetch (new terms), and the return to "mouse" is a CACHE HITcache entries -> 3 (catalog + two searches), search fetches -> 2 (the return cost nothing)—. In (c), the revalidation: after the sale and past staleTime, the catalog shows old $25.99 in render 1 (isFetching=true) and fresh $19.99 in render 2, with 1 fetch, no spinner. That's Mercado's server state solved: a shared copy of the catalog, one entry per search, automatic revalidation —all declared, nothing orchestrated—.

Common mistakes

Putting the products in a store or in Context "to share them". What happens: since the catalog is used by many components, it's stored in the cart's store or in a Context. Why it happens: "the whole app shares it, it's global". How to detect it: you store a fetch response in a store, and then you fight by hand to keep it fresh (loading flags, manual refetch). How to fix it: "how many use it" doesn't decide the box; whose truth it is does (M4). The products' truth lives in the backend → server state → React Query. The store and Context are for client state.

Creating a QueryClient per component or inside App. What happens: new QueryClient() is instantiated in the render, so it's recreated and the cache is lost. Why it happens: it's not seen that the cache must be unique and stable. How to detect it: there's no dedup nor cache across components; each requests on its own. How to fix it: one QueryClient, created outside the tree (at module level), provided with QueryClientProvider. A stable cache is what allows sharing copies.

Using the same queryKey for the catalog and the searches. What happens: SearchResults reuses ['products'], so the searches cross with the catalog or with each other. Why it happens: "they're products". How to detect it: searching shows the complete catalog, or the results of one term under another. How to fix it: the catalog is ['products']; a search is ['products', { query }] with the term in the key (L3). Different data, different keys.

Exercises

Exercise 1 — Add the detail page. You want a product detail page that shows a product by its id and its reviews. (a) Write the two useQuery with their queryKey. (b) If the user navigates from the catalog to product p1's detail and then goes back to the catalog, does the catalog reload? (c) Why doesn't the detail share a cache with the catalog even though it shows the same product?

See solution

(a)

const { data: product } = useQuery({ queryKey: ['product', id],            queryFn: () => fetchProduct(id) });
const { data: reviews } = useQuery({ queryKey: ['product', id, 'reviews'], queryFn: () => fetchReviews(id) });

(b) It doesn't reload (if you return within gcTime, 5 min by default, and the data is still fresh per staleTime): the ['products'] entry is still in the cache, so the catalog shows instantly (cache hit). If staleTime already passed, it shows instantly and revalidates in the background (swr) —no spinner in any case—.

(c) Because they have different queryKey: the catalog is ['products'] (the complete list) and the detail is ['product', 'p1'] (a product). The cache treats them as different data, each its entry —even though p1 appears in both—. Sharing p1's data between the two would require cache-syncing techniques (like setQueryData on navigating), which are an advanced topic; by default, different keys = different entries.

Exercise 2 — Read the scorecard. From the scorecard, explain each row in terms of the library analogy: (a) why manual gives 3 fetches and React Query 1; (b) why the manual copy stays at $25.99 and React Query's goes to $19.99; (c) what "data identity: none" vs "queryKey" means.

See solution
  • (a) 3 vs 1. Manual: three readers each go to the publisher for the same book (three useEffect, three fetches). React Query: the three request the same title from the librarian, she goes once and gives everyone the copy (dedup, one request).
  • (b) $25.99 vs $19.99. Manual: each reader kept their copy and nobody goes back to the publisher, so when the new edition comes out (the price drops) they keep the old one ($25.99, stale forever). React Query: the librarian, after a while, checks if there's a new edition and replaces the copy ($19.99, revalidates).
  • (c) none vs queryKey. Manual: each order is anonymous, without a receipt number, so there's no way to know that two readers want the same → they don't coordinate. React Query: each data has its queryKey (its receipt number), and that's why the librarian can share copies, deduplicate and revalidate. The identity is what allows all the coordination.

Exercise 3 — Which lesson solves what? The project touches the whole module. For each piece, say which lesson the underlying concept is from: (a) the queryClient created outside App and provided with QueryClientProvider; (b) ProductList and FeaturedGrid sharing ['products']; (c) SearchResults with ['products', { query }]; (d) the staleTime: 60_000 and the background revalidation; (e) the isLoadingisErrordata order.

See solution
  • (a) queryClient outside the treeuseQuery and where the cache lives (L2) + a global cache, not per component (L4): the single cache, outside the tree, provided to the whole app.
  • (b) shared ['products']the cache and the dedup (L4): the same queryKey → one copy, one fetch for the two components.
  • (c) ['products', { query }]the queryKey is the identity (L3): the term in the key → one entry per search, without crossing data.
  • (d) staleTime + revalidationstale-while-revalidate (L5): within staleTime it serves the cache; past it, cache instantly + background refetch.
  • (e) isLoadingisErrordatathe states (L6): the canonical order that avoids reading data blindly.

The project integrates the module: classify (L1), declare with useQuery (L2), design the queryKey (L3), leverage cache/dedup (L4), revalidate with staleTime (L5), and render the states (L6). The products ended up in their box, with the right tool.

Summary and next step

In this mini-project you managed Mercado's products end to end with React Query. You started with the classification table —products → React Query; search → URL (M7); cart → store (M3); theme/user → Context (M2); menu → local—, which makes the decision explicit before writing code. You set up the fetching with a QueryClient outside the tree, the catalog with useQuery(['products']) (dedup, revalidation, states) and the search with useQuery(['products', { query }]) (one entry per term). And you executed it in a session with the before/after scorecard: manual 3 fetches and stale copy $25.99 against React Query 1 fetch (dedup) and revalidated $19.99, plus the catalog (1 fetch), the searches (2 fetches + cache hit on returning to "mouse") and the revalidation (swr, no spinner). That's the whole module working together: classify, declare data by their queryKey, and let the layer share, deduplicate, revalidate and expose states.

Before closing the module you should be able to: classify a storefront's state and place the products in React Query; set up the QueryClientProvider and the catalog's and search's useQuery; explain the before/after scorecard; and justify why the products don't go in the store nor in Context.

And where the guide goes next. With this module you finished the reading of server state: you declare what data you need and React Query brings it, caches, deduplicates and revalidates. But you've only read. In the session, the price was lowered by "the admin" —a black box—; you never changed it yourself from the UI. Module 6 opens that side: the mutations. You're going to learn useMutation to write (create, edit, delete), the write → invalidate → refetch cycle (after writing, you invalidate the affected queries so they revalidate with the new truth), and the optimistic updates (updating the UI before the response and rolling back if it fails). With the reading (M5) and the writing (M6), server state is complete; then will come the URL (M7) and the capstone (M8).

Resources