Module 4: Server State Is Different
Mini-project: audit Mercado's product fetching
Overview
The moment has come to bring the whole module together in a single piece applied to Mercado. In this mini-project you do an audit of the storefront's product fetching: you take the real code that shows the products with useState + useEffect (react-fundamentals' pattern), put it to the test in the module's four scenarios, and measure the before (broken) against the after (a cache layer). The deliverable is twofold: an executed scorecard that compares both —fetches, number of copies, coherence after a backend change— and a requirements table that specifies what the cache layer must give. That table is, literally, the checklist that modules 5 and 6 fulfill with React Query. You don't build the production solution here; you diagnose it precisely and leave written what it has to solve.
It's an audit, not a redesign, on purpose: module 4's goal is that you come out able to look at fetching code and see the ills, measure them, and name the cure. An engineer who can audit like this understands React Query when they see it —they recognize each option as the answer to an ill they already diagnosed—, instead of copying recipes. Everything runs in Node: the before and the after run for real, with real counters, so the scorecard isn't an opinion but a measurement.
Connection with the module. It's the diagnosis capstone. It brings together the six lessons: the nature of server state (L2), its four properties (L3), and the five measured ills —no cache/dedup (L4), race/no revalidation (L5), repeated loading/error (L6)— along with the single cure (L7). And it closes the module's boundary: here you diagnose Mercado's problem and specify the layer; the implementation with React Query —useQuery, queryKey, staleTime, useMutation, invalidation— is modules 5 and 6. With this you finish module 4 and are ready for the solution.
The plan: what we're going to audit
The audit has three parts, in order:
- Measure the before. Mercado's manual fetching —
ProductList,FeaturedGrid,SearchResults, each with itsuseEffect(fetch('/products'), [])— put to the test: how many fetches it makes on mount, and what happens when an admin applies a sale (the copies diverge). - Measure the after. The same components reading from a cache layer by
queryKey: how many fetches, how many copies, and what happens after the sale (all fresh). - Deliver the diagnosis. The before/after scorecard and the layer's requirements table —the checklist for module 5—.
The tree and the problem
This is the storefront with the three components that show products, each requesting on its own from the backend. Notice the three arrows to the same endpoint:
flowchart TD
App[App]
PL["ProductList (useEffect: fetch /products)"]
FG["FeaturedGrid (useEffect: fetch /products)"]
SR["SearchResults (useEffect: fetch /products)"]
Backend[("Backend (the truth of /products)")]
App --> PL
App --> FG
App --> SR
PL -. "fetch #1" .-> Backend
FG -. "fetch #2" .-> Backend
SR -. "fetch #3" .-> Backend
Three components, three useEffect, three requests to the same /products, three copies of the same data. It's the scenario the whole module has been measuring. The audit quantifies it and contrasts it with the cure: a single cache by queryKey, read by the three.
The real React code: the before and the after
Here's how Mercado's fetching looks today (the before) and how it will look with React Query (the after). Read them side by side; the second is the goal module 5 implements.
The before — each component with its useEffect (three copies, three machines).
// ProductList.jsx — its own copy and its own loading/error machine
function ProductList() {
const [products, setProducts] = useState(null);
const [isLoading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/products')
.then((r) => r.json())
.then((d) => { setProducts(d); setLoading(false); })
.catch((e) => { setError(e.message); setLoading(false); });
}, []); // runs once; never revalidates
if (isLoading) return <Spinner />;
if (error) return <Error msg={error} />;
return <ul>{products.map((p) => <ProductCard key={p.id} product={p} />)}</ul>;
}
// FeaturedGrid and SearchResults repeat EXACTLY this block -> 3 fetches, 3 copies, 3 machines.
The after — everyone reads from the layer by the same queryKey (one copy, one machine).
// With React Query: each component DECLARES the data by its queryKey; the layer does the rest.
function ProductList() {
const { data: products, isLoading, isError } = useQuery({
queryKey: ['products'], // the data's identity
queryFn: () => fetch('/products').then((r) => r.json()),
});
if (isLoading) return <Spinner />;
if (isError) return <Error />;
return <ul>{products.map((p) => <ProductCard key={p.id} product={p} />)}</ul>;
}
// FeaturedGrid and SearchResults use the SAME queryKey ['products']:
// they share a copy -> 1 fetch, dedup, shared state, and revalidation via invalidateQueries.
The difference isn't in how much code you write, but in where the state lives: in the before, inside each component (it duplicates); in the after, in the layer by queryKey (it's shared). Everything this project audits comes from that difference.
Worked example: the audit, executed
We're going to run the audit: the before (manual fetching) and the after (cache layer) in the same scenarios, with real counters, and at the end the scorecard and the requirements table.
// MINI-PROJECT M4: audit Mercado's product fetching.
// We measure the BEFORE (useState+useEffect in each component) against the AFTER (a cache layer),
// in the same scenarios: cache, dedup, revalidation, loading/error coherence.
const backend = {
calls: 0,
data: { products: [{ id: 'p1', name: 'Wireless Mouse', priceCents: 2599 }] },
fetch(key) { this.calls++; return JSON.parse(JSON.stringify(this.data[key])); },
};
const price = (list) => '$' + (list[0].priceCents / 100).toFixed(2);
console.log('=== Audit: Mercado\'s product fetching ===\n');
// The storefront: components that show the SAME products (same remote truth).
const COMPONENTS = ['ProductList', 'FeaturedGrid', 'SearchResults'];
// =========================================================================
// BEFORE — each component with its useState+useEffect (own copy)
// =========================================================================
console.log('--- BEFORE: useState + useEffect in each component ---\n');
backend.calls = 0;
const copies = {}; // each component stores ITS copy
COMPONENTS.forEach((name) => { copies[name] = backend.fetch('products'); }); // 3 fetches on mount
console.log('1) the 3 components mount -> fetches: ' + backend.calls + ' (no cache: one per component)');
// The backend lowers the price; only SearchResults asks again (the others have deps [])
backend.data.products[0].priceCents = 1999;
copies['SearchResults'] = backend.fetch('products'); // only this one refetches
console.log('\n2) the price drops to $19.99; only SearchResults re-requests:');
COMPONENTS.forEach((name) => {
const tag = price(copies[name]) === '$19.99' ? 'fresh' : 'STALE';
console.log(' ' + name.padEnd(15) + ' shows ' + price(copies[name]) + ' <- ' + tag);
});
console.log(' >> the SAME mouse with two prices at once on screen (copies that diverge).');
console.log('\n BEFORE score: fetches=' + backend.calls + ', copies=' + COMPONENTS.length +
', coherent=NO');
// =========================================================================
// AFTER — a cache layer by queryKey (one shared copy)
// =========================================================================
console.log('\n--- AFTER: a cache layer by queryKey ---\n');
function createCacheLayer(backend) {
const entries = new Map();
function ensure(key) {
if (!entries.has(key)) entries.set(key, { status: 'idle', data: null, error: null });
return entries.get(key);
}
return {
useQuery(key) {
const e = ensure(key);
if (e.status === 'idle') { e.status = 'success'; e.data = backend.fetch(key); } // 1 fetch, dedup
return e;
},
invalidate(key) { ensure(key).status = 'idle'; },
};
}
// we reset the backend to compare from the same point
backend.data.products[0].priceCents = 2599;
backend.calls = 0;
const cache = createCacheLayer(backend);
const results = COMPONENTS.map((name) => cache.useQuery('products')); // 3 reads, 1 fetch
console.log('1) the 3 components mount -> fetches: ' + backend.calls + ' (with cache+dedup: only one)');
console.log(' do they read the SAME object? -> ' + (results[0] === results[1] && results[1] === results[2]));
console.log('\n2) the price drops to $19.99; we invalidate the queryKey ONCE:');
backend.data.products[0].priceCents = 1999;
cache.invalidate('products');
const afterList = COMPONENTS.map((name) => cache.useQuery('products')); // first refetches, rest cache
COMPONENTS.forEach((name, i) => {
console.log(' ' + name.padEnd(15) + ' shows ' + price(afterList[i].data) + ' <- fresh');
});
console.log(' >> a single truth: impossible for them to diverge.');
console.log('\n AFTER score: fetches=' + backend.calls + ', copies=1, coherent=YES');
// =========================================================================
// Scorecard + layer requirements (what M5-M6 build)
// =========================================================================
console.log('\n=== Scorecard ===\n');
console.log(' Scenario | BEFORE (useState+useEffect) | AFTER (cache layer)');
console.log(' --------------------------|-----------------------------|--------------------');
console.log(' fetches on mount (3 comp) | 3 | 1');
console.log(' copies of the data | 3 (one per component) | 1 (by queryKey)');
console.log(' after backend change | diverge (25.99/25.99/19.99) | all fresh');
console.log(' loading/error state | 3 independent machines | 1 shared state');
console.log('\n=== Layer requirements (what the tool must give) ===\n');
[
'index each remote data by a queryKey (unique identity)',
'cache: the 2nd read does NOT refetch',
'dedup: identical in-flight requests are shared',
'revalidate: invalidate a key -> controlled refetch',
'expose a state {isLoading, isError, data} per key',
'discard old responses (race safety)',
].forEach((r, i) => console.log(' ' + (i + 1) + '. ' + r));
console.log('\nThat tool is React Query / TanStack Query. The diagnosis is done;');
console.log('the solution (useQuery, the cache mechanic) is module 5.');
What to expect. When you run the file with Node, the output is exactly this:
=== Audit: Mercado's product fetching ===
--- BEFORE: useState + useEffect in each component ---
1) the 3 components mount -> fetches: 3 (no cache: one per component)
2) the price drops to $19.99; only SearchResults re-requests:
ProductList shows $25.99 <- STALE
FeaturedGrid shows $25.99 <- STALE
SearchResults shows $19.99 <- fresh
>> the SAME mouse with two prices at once on screen (copies that diverge).
BEFORE score: fetches=4, copies=3, coherent=NO
--- AFTER: a cache layer by queryKey ---
1) the 3 components mount -> fetches: 1 (with cache+dedup: only one)
do they read the SAME object? -> true
2) the price drops to $19.99; we invalidate the queryKey ONCE:
ProductList shows $19.99 <- fresh
FeaturedGrid shows $19.99 <- fresh
SearchResults shows $19.99 <- fresh
>> a single truth: impossible for them to diverge.
AFTER score: fetches=2, copies=1, coherent=YES
=== Scorecard ===
Scenario | BEFORE (useState+useEffect) | AFTER (cache layer)
--------------------------|-----------------------------|--------------------
fetches on mount (3 comp) | 3 | 1
copies of the data | 3 (one per component) | 1 (by queryKey)
after backend change | diverge (25.99/25.99/19.99) | all fresh
loading/error state | 3 independent machines | 1 shared state
=== Layer requirements (what the tool must give) ===
1. index each remote data by a queryKey (unique identity)
2. cache: the 2nd read does NOT refetch
3. dedup: identical in-flight requests are shared
4. revalidate: invalidate a key -> controlled refetch
5. expose a state {isLoading, isError, data} per key
6. discard old responses (race safety)
That tool is React Query / TanStack Query. The diagnosis is done;
the solution (useQuery, the cache mechanic) is module 5.
Read the audit from start to finish, because it's the whole module applied to Mercado.
The before, measured. The three components mounted and made fetches: 3 —each useEffect requested /products on its own—. Then an admin lowered the price to $19.99, and only SearchResults asked again (imagine it remounted, or its effect fired again); the other two, with [], stayed with their old copy. Result: ProductList and FeaturedGrid show $25.99 (stale) while SearchResults shows $19.99 (fresh) —the same mouse with two prices at once on screen, the diverging-copies bug from module 1—. The before score: fetches=4 (three on mount + one from the partial refetch), copies=3, coherent=NO.
The after, measured. The same three components, now reading from the layer by 'products', mounted with fetches: 1 —one requested, the other two read the copy (do they read the SAME object? -> true)—. When the price dropped to $19.99, a single invalidate('products') was enough for the only copy to refresh: the three show $19.99 (fresh), because they read the same entry. >> a single truth: impossible for them to diverge. The after score: fetches=2 (one on mount + one after invalidating), copies=1, coherent=YES.
The scorecard sums up the audit in four rows, and all tell the same story: the before has the problem (3 fetches, 3 copies, diverge, 3 machines); the after cures it (1 fetch, 1 copy, all fresh, 1 shared state). It isn't an opinion: they're numbers from a run. And the difference, in the four rows, is the same decision —one copy per queryKey instead of one per component—.
The requirements table is the project's most valuable deliverable: six points the layer must fulfill. Read it as the contract module 5 is going to sign —each requirement is a React Query feature—: queryKey (identity), cache, dedup, invalidateQueries (revalidate), isLoading/isError/data (shared state), and the discarding of old responses (race safety). When in module 5 you write your first useQuery, this table will be the list of things that line of code is giving you free.
Common mistakes
Auditing without measuring (opining instead of counting). What happens: you say "the fetching is wrong" without numbers, and the team isn't convinced. Why it happens: the problem is invisible in the demo, so without measurement it seems theoretical. How to detect it: discussions about whether it's "worth" changing, without data. How to fix it: measure —count the fetches in the network tab, reproduce the divergence after a backend change, show the scorecard—. The numbers (3 vs 1, diverge vs fresh) make the case that opinions don't.
Confusing the diagnosis with the solution. What happens: after the audit, you try to implement the production layer by hand in the same commit. Why it happens: the minimal model looks simple and the impulse to "fix it now" is strong. How to detect it: you start writing your own cache with staleTime, refetch on focus, retries… reimplementing React Query. How to fix it: this project diagnoses and specifies; the implementation is adopting React Query (M5), not writing the layer. The requirements table is the bridge: it says what is needed, and the tool fulfills it.
Auditing only the products and forgetting the rest of the state. What happens: you fix the product fetching and consider Mercado's state management done. Why it happens: the products were the module's focus. How to detect it: the cart, the theme or the search are still misclassified. How to fix it: remember module 1's complete picture —the products are server (React Query), but the cart is global client (store, M3), the theme is stable global client (Context, M2), and the search/filters go in the URL (M7)—. The server audit is one piece; each box has its tool.
Exercises
Exercise 1 — Extend the audit. Mercado adds a Recommendations and a ProductDetailPanel, both showing the same /products. (a) In the before, how many fetches on mount (with the five components)? (b) In the after, how many? (c) After a sale, how many copies can diverge in each case?
See solution
- (a) Five. Each of the five components runs its own
useEffect(fetch('/products'), [])on mount —five islands, five requests—. - (b) One. With the layer by
'products', the first to mount makes the real request and stores the copy; the other four read it. Five consumers, one fetch. - (c) In the before, up to five copies can diverge: each component has its own, and after a sale some update and others don't, so you could see up to five different prices of the same product. In the after, zero: there's a single copy per
queryKey, so invalidating it refreshes the five at once —impossible for them to diverge—. The more components show the data, the worse the before and the more obvious the value of the layer.
Exercise 2 — Trace the requirement. For each requirement in the table, say which module ill it solves and in which lesson you measured it: (a) "cache: the 2nd read does not refetch"; (b) "discard old responses"; (c) "a state {isLoading, isError, data} per key"; (d) "invalidate a key → refetch".
See solution
- (a) cache → solves the no cache ill (N components → N fetches), measured in lesson 4 (3 fetches → 1).
- (b) discard old responses → solves the race condition (out-of-order responses paint the old one), measured in lesson 5 (the guard with an increasing id).
- (c) a state per key → solves the repeated loading/error (machines that desync), measured in lesson 6 (three different states for the same data → one shared).
- (d) invalidate → refetch → solves the lack of revalidation (the copy goes stale because
useEffect([])runs once), measured in lesson 5 (stale copy → layer that revalidates).
And the requirement that supports them all, the queryKey, is the identity that allows cache, dedup, invalidation and shared state —the coordinator raw useEffect doesn't have (lesson 4)—.
Exercise 3 — Write the verdict. Write, in three or four sentences, the audit verdict you'd present to the team: what problem the current fetching has (with a number), what cure is proposed, and what tool implements it. Be concrete.
See solution
A possible verdict:
"The product fetching uses useState + useEffect in each component, which produces 3 requests to the same /products on loading the home (one per component that shows it) and, after any backend change, copies that diverge —we saw the same product with two prices at once on screen—, plus incoherent loading/error states across components. The cause is having a copy of the data per component instead of one shared copy. The cure is a cache layer by queryKey that centralizes a single copy per remote data —dropping the fetches to 1, eliminating the divergence and unifying the loading state—. That layer is React Query / TanStack Query, which fulfills the six requirements of the table; we propose adopting it for server state (the products), keeping the cart in the store and the theme in Context."
The important thing about the verdict: it carries a number (3 fetches, divergence), names the cause (copy per component), the cure (layer by queryKey) and the tool (React Query) —without implementing it, because that's module 5—.
Summary and next step
In this mini-project you audited Mercado's product fetching end to end. You measured the before —useState + useEffect in each component: 3 fetches on mount, 3 copies that diverge after a sale (the same mouse with two prices), 3 incoherent loading/error machines— against the after —a cache layer by queryKey: 1 fetch, 1 shared copy, all fresh after an invalidate, 1 shared state—. You delivered the executed scorecard (numbers, not opinions) and the layer's requirements table: index by queryKey, cache, dedup, revalidate, expose a shared state and discard old responses. That's the whole module applied: see the ills, measure them, and specify the cure —without implementing it, because the implementation is the next module's solution—.
Before closing the module you should be able to: audit fetching code and measure its ills; contrast the before/after with numbers; write a verdict with cause, cure and tool; and write the requirements table the layer must fulfill.
And where the guide goes next. With this module you finished the diagnosis of server state: you know it isn't yours (it's a cache), you know its four properties, you measured the five ills of manual fetching, and you specified the layer that cures them. Module 5 settles the debt: it teaches you React Query / TanStack Query, the real layer, with its mechanic —useQuery, the queryKey, the cache, the stale-while-revalidate (show the copy instantly and revalidate in the background), the request dedup, and the isLoading/isError/data states—, all executed with a mini-cache so you see inside what the requirements table asked. And module 6 closes with the mutations: useMutation, the write → invalidate → refetch cycle, and the optimistic updates. The table you wrote today is the index of those two modules: each requirement, a lesson.
Resources
- TanStack Query, "Overview" — tanstack.com/query/latest/docs/framework/react/overview. The tool that fulfills this audit's requirements table. The starting point of module 5. In English.
- TkDodo, "Why You Want React Query" — tkdodo.eu/blog/why-you-want-react-query. The accounting of the ills the audit measured, by the library's maintainer —the verdict, in article form—. In English.
- TkDodo, "React Query as a State Manager" — tkdodo.eu/blog/react-query-as-a-state-manager. Why server state (the products) goes in React Query and not in the client store (the cart) —the classification that closes the audit—. In English.
- React, "You Might Not Need an Effect" (section "Fetching data") — react.dev/learn/you-might-not-need-an-effect#fetching-data. Why the
useState+useEffectpattern we audited is fragile, according to React's official doc. In English.