Module 4: Server State Is Different
Race conditions and stale copies
Overview
Lesson 4 measured two ills of manual fetching that are about quantity (how many requests are made). This one measures two that are about when: at which moment it arrives and at which moment it's requested again. The first is the race condition: when you fire two requests in a row —for example, the user types "mou" and right after "mouse" in the search—, the responses can arrive out of order, and manual fetching, which paints whatever arrives last, ends up showing the old response under the new text. The second is the lack of revalidation: useEffect(() => {...}, []) runs only once, on mount, so the copy it brought stays stale forever when the backend changes —there's nothing to trigger a second request—. Both are timing bugs, and both force you to write, by hand and in each component, delicate logic that's easy to forget.
The search race condition is the one react-fundamentals M6 barely flagged: it showed you that a useEffect that does fetch may need an "ignore flag" or a cleanup so as not to apply old responses, and left it there. This module closes that loose end: it shows you the bug measured, the cure (an increasing request identifier to discard the old ones), and —the important thing— why writing that cure in each component that fetches doesn't scale. The lack of revalidation, for its part, is the direct consequence of the "goes stale" property (lesson 3): without a policy of when to request again, the copy ages and nobody refreshes it.
Connection with the module. Lessons 4 and 5 together cover four of the five ills of manual fetching: no cache, no dedup (L4), race and no revalidation (L5). Lesson 6 covers the fifth (repeated loading/error), and lesson 7 shows the layer that solves the five. Here you'll see why handling the "when" by hand —discarding old responses, deciding when to refetch— is especially fragile: it doesn't fail in the demo, it fails in production with a real network and fast users. The integrated cure (a layer that discards old responses by a seq and revalidates according to a policy) is what React Query brings done (M5); here you see both mechanics executed.
An analogy: two letters that arrive out of order and the snapshot nobody refreshes
Two images, one for each ill.
The race condition: two letters out of order. You write to a friend on Monday: "let's meet Tuesday". On Tuesday you change your mind and write another letter: "better Thursday". You sent two letters, the second cancels the first. But the mail is capricious: the Thursday letter (the new one) arrives Wednesday, and the Tuesday one (the old one) arrives Thursday —out of order—. If your friend follows "the last one that arrived", they'll believe you agreed on Tuesday, because that letter arrived last, even though it's the oldest instruction. The bug isn't the mail's: it's the rule "follow the last one that arrived" when the letters can arrive out of order. The correct rule is "follow the most recent one I wrote, no matter when it arrives" —and for that you have to number the letters—.
The revalidation: the snapshot nobody refreshes. Go back to the bank balance. You take a snapshot of the balance at 9:00 —"$1,000"— and stick it on the wall. That snapshot doesn't update on its own: at 11:00, when the real balance is "$1,200", the snapshot on the wall still says $1,000, forever, because nobody took a new snapshot. useEffect(fetch, []) is exactly that: you take the snapshot on mount and stick it; since the effect has [], it never runs again, so nobody takes a new snapshot. The cure is to have a policy: "I take a new snapshot when I look at the wall again (return to the tab), or when I know there was a movement (a mutation), or every so often". Without a policy, the snapshot ages and lies.
Worked example: the search that paints the old, and the copy that never revalidates
We model in Node the two ills and their cures. For the race, a network that delivers the responses in the order we indicate (deterministic, no real promises), to force the disorder. First, how the problem looks in React —the search useEffect M6 left with the warning—:
// The user types; each change fires a fetch. The responses can come back OUT OF ORDER.
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
fetch('/search?q=' + query)
.then((r) => r.json())
.then(setResults); // <-- paints whatever arrives, old or new: race BUG
}, [query]);
return <ul>{results.map((r) => <li key={r}>{r}</li>)}</ul>;
}
// react-fundamentals M6 warned: here an "ignore flag" is needed in the cleanup. Let's see it.
Let's execute the two ills and their cures:
// Why useState+useEffect is BAD at scale (part 2): RACE and no REVALIDATION.
// Both are bugs of "when" the fetch happens. Simulated network: we deliver responses
// in the order we choose (deterministic, no real promises).
console.log('=== useState+useEffect: race conditions and no revalidation ===\n');
// ---------- 1) RACE: two searches that resolve OUT OF ORDER ----------
// The user types "mou" and then "mouse". Two fetches fire.
// The network returns "mouse" (the new) first and "mou" (the old) AFTER.
const resultsFor = {
mou: ['Mouse', 'Mousepad', 'Mouse Wireless'], // 3 results
mouse: ['Mouse', 'Mouse Wireless'], // 2 results
};
console.log('1) RACE — two searches resolve out of order');
console.log(' the user types "mou" and then "mouse" (the final input says "mouse")');
console.log(' the network responds "mouse" first and "mou" after\n');
// NAIVE: each response that arrives OVERWRITES the state, regardless of whether it's already old.
let displayedNaive = null;
function onResponseNaive(query) { displayedNaive = resultsFor[query]; }
onResponseNaive('mouse'); // the NEW response arrives
onResponseNaive('mou'); // the OLD response arrives after and overwrites it
console.log(' NAIVE (always overwrites): input="mouse" but shows ' + JSON.stringify(displayedNaive));
console.log(' -> BUG: results of "mou" under the text "mouse"');
// GUARDED: each request carries an increasing id; only the MOST RECENT can paint.
let latestId = 0, displayedGuarded = null;
function dispatch() { latestId++; return latestId; } // id of this request
function onResponseGuarded(query, reqId) {
if (reqId === latestId) displayedGuarded = resultsFor[query]; // ignores old responses
}
const idMou = dispatch(); // request 1: "mou"
const idMouse = dispatch(); // request 2: "mouse" (the last)
onResponseGuarded('mouse', idMouse); // reqId 2 == latestId 2 -> paints
onResponseGuarded('mou', idMou); // reqId 1 != latestId 2 -> ignored
console.log(' GUARDED (increasing id): input="mouse" and shows ' + JSON.stringify(displayedGuarded));
console.log(' -> correct: the old response is discarded');
console.log(' (this guard must be written BY HAND in each component that fetches)\n');
// ---------- 2) NO REVALIDATION: useEffect(() => ..., []) runs ONCE ----------
const backend = {
product: { name: 'Wireless Mouse', priceCents: 2599 },
fetch() { return { ...this.product }; },
};
const money = (c) => '$' + (c / 100).toFixed(2);
console.log('2) NO REVALIDATION — useEffect(fetch, []) runs only on mount');
let mounted = false;
let copy = null;
function componentMountNaive() { // useEffect(() => fetch(), [])
if (!mounted) { copy = backend.fetch(); mounted = true; } // runs ONCE only
}
componentMountNaive();
console.log(' on mount: ' + money(copy.priceCents));
backend.product.priceCents = 1999; // the backend changes afterward
componentMountNaive(); // the effect does NOT run again (deps [])
console.log(' backend changed to ' + money(backend.fetch().priceCents) + ', but the effect doesn\'t re-run');
console.log(' the copy stays: ' + money(copy.priceCents) + ' <- STALE forever\n');
// WITH a revalidation policy: the layer asks again at certain moments.
backend.product.priceCents = 2599; // we reset the backend to see revalidation from scratch
const cache = {
data: null, stale: true,
read() { return this.data; },
markStale() { this.stale = true; }, // "what I have is no longer trustworthy"
revalidate() { if (this.stale) { this.data = backend.fetch(); this.stale = false; } },
};
cache.revalidate();
console.log(' WITH revalidation (the layer asks again):');
console.log(' on mount: ' + money(cache.read().priceCents));
backend.product.priceCents = 1999; // the backend changes again
cache.markStale(); // e.g. the user returned to the tab, or there was a mutation
cache.revalidate(); // the layer refetches
console.log(' backend changed to ' + money(backend.fetch().priceCents) + '; the layer marks stale and revalidates');
console.log(' after revalidating: ' + money(cache.read().priceCents) + ' <- FRESH');
console.log('\nBoth are "when" bugs: the race paints an old response; the [] never refetches.');
console.log('Handling the "when" by hand in each component doesn\'t scale. A layer centralizes it.');
What to expect. When you run the file with Node, the output is exactly this:
=== useState+useEffect: race conditions and no revalidation ===
1) RACE — two searches resolve out of order
the user types "mou" and then "mouse" (the final input says "mouse")
the network responds "mouse" first and "mou" after
NAIVE (always overwrites): input="mouse" but shows ["Mouse","Mousepad","Mouse Wireless"]
-> BUG: results of "mou" under the text "mouse"
GUARDED (increasing id): input="mouse" and shows ["Mouse","Mouse Wireless"]
-> correct: the old response is discarded
(this guard must be written BY HAND in each component that fetches)
2) NO REVALIDATION — useEffect(fetch, []) runs only on mount
on mount: $25.99
backend changed to $19.99, but the effect doesn't re-run
the copy stays: $25.99 <- STALE forever
WITH revalidation (the layer asks again):
on mount: $25.99
backend changed to $19.99; the layer marks stale and revalidates
after revalidating: $19.99 <- FRESH
Both are "when" bugs: the race paints an old response; the [] never refetches.
Handling the "when" by hand in each component doesn't scale. A layer centralizes it.
Break down the two ills.
1) The race, measured. The user typed "mou" and then "mouse" —the final input says "mouse"—. The network returned "mouse" first and "mou" after. The naive version paints whatever arrives, so the last to arrive ("mou") won: the UI shows ["Mouse","Mousepad","Mouse Wireless"] —the results of "mou" under the text "mouse"—. It's the old letter that arrived last and your friend followed it. The guarded version numbers each request with an increasing id and only paints if the response corresponds to the most recent request (reqId === latestId): when the old "mou" response arrived (id 1) but the last request was "mouse" (id 2), it discarded it. The UI shows ["Mouse","Mouse Wireless"] —correct—. The cure works, but notice the note: that guard must be written by hand in each component that fetches. A search useEffect without that guard is a bug waiting for a user who types fast with a slow network.
2) The revalidation, measured. The manual copy was made on mount ($25.99); the backend lowered the price to $19.99; the effect, with [], didn't run again, so the copy stayed at $25.99 —stale forever—. It's the snapshot stuck on the wall that nobody refreshes. The with revalidation version does what the [] doesn't: when it knows the copy may have aged (the user returned to the tab, there was a mutation, some time passed), it marks it stale and revalidates —requests again—, getting $19.99 fresh. The difference isn't "more code for no reason": it's that server state demands a policy of when to refetch, and useEffect(fetch, []) has none.
The two ills share a lesson: manual fetching forces you to handle the "when" —when to discard a response, when to request again— by hand and in each component, and those details are easy to forget and hard to reproduce. They don't fail in the demo (a single component, instant network, data that doesn't change); they fail in production (many components, variable network, truth that moves). A layer centralizes that "when" once, well, for everyone.
Why the manual "ignore flag" doesn't scale
The race cure react-fundamentals M6 mentioned is the "ignore flag" in the useEffect's cleanup:
useEffect(() => {
let ignore = false; // this run "is still current"
fetch('/search?q=' + query)
.then((r) => r.json())
.then((data) => { if (!ignore) setResults(data); }); // only paints if still current
return () => { ignore = true; }; // on query change, invalidates the previous run
}, [query]);
It's correct, and it's the React version of the example's increasing id: each run of the effect has its flag, and the cleanup lowers it when query changes, so the responses from old runs are discarded. But look at what it implies: each component that fetches needs this pattern —the flag, the if (!ignore) check, the cleanup—, written by hand, without forgetting any part. Multiply it by the ten, twenty, fifty places of an app that request data, and add that they also need cache, dedup, loading, error and revalidation (the other ills), and you'll see why the ecosystem doesn't write this by hand: it delegates it to a layer. A server-state library does the discarding of old responses for you, in a single tested place, for all the queries. You declare what data you want; it takes care of the "when".
Common mistakes
Ignoring the race because "in the demo it never happens". What happens: the search useEffect paints whatever arrives, without a guard, and it works in development. Why it happens: with an instant network and a single user typing slowly, the responses almost always arrive in order. How to detect it: in production, reports of "the search shows results of what I typed before", intermittent and impossible to reproduce at will. How to fix it: every search or fetch dependent on a value that changes needs to discard old responses —the ignore flag or, better, a layer that does it for you—. It isn't a rare case; it's what happens as soon as the network is real.
Believing useEffect(fetch, []) "updates on its own". What happens: it's assumed that, since the component "is mounted", the data stays fresh. Why it happens: "mounted" is confused with "alive and synced". How to detect it: the app shows old data after backend changes, and it's only fixed by reloading the page. How to fix it: [] means "run once and never again". Without a revalidation policy (on window focus, after a mutation, by time), the copy stays stale. Revalidation is a design decision, not something automatic of the useEffect.
Putting dependencies in the useEffect to "force" refetch and creating loops. What happens: wanting the effect to refetch, objects or functions are added to the dependencies, and since they're recreated on each render, the effect runs in a loop. Why it happens: revalidation is attempted by pushing on the dependency array. How to detect it: requests firing nonstop, the network tab flooded. How to fix it: revalidation isn't achieved by manipulating fragile dependencies; it's achieved with an explicit policy (invalidate the query, refetch on focus, staleTime). That's exactly the job of a cache layer —you'll see it in L7 and in React Query—.
Exercises
Exercise 1 — Predict the race. The user types "ke", then "key", then "keyb" in the SearchBar. The network returns the responses in this order: "key" (2nd), "keyb" (3rd), "ke" (1st). With the naive version (paints whatever arrives), what results does the UI show at the end, and what text is in the input? And with the guarded version?
See solution
The final input says "keyb" (it was the last thing the user typed). The responses arrive in the order: "key", "keyb", "ke".
- Naive (paints whatever arrives): the last to arrive is "ke", so the UI ends up showing the results of "ke" under the text "keyb". Bug: it shows results for two letters when the user typed four. It's exactly the oldest letter arriving last.
- Guarded (increasing id): the requests are numbered 1="ke", 2="key", 3="keyb";
latestIdends at 3. When "key" arrives (id 2 ≠ 3) it's discarded; when "keyb" arrives (id 3 == 3) it's painted; when "ke" arrives (id 1 ≠ 3) it's discarded. The UI shows the results of "keyb" —correct—, regardless of the arrival disorder.
The key: the naive one is guided by arrival order; the guarded one, by which is the most recent request. Only the second is correct when the network reorders.
Exercise 2 — Design the revalidation policy. For Mercado's catalog, useEffect(fetch, []) leaves the copy stale. Propose at least three concrete moments in which it would make sense to revalidate the products, and say which backend change each one covers.
See solution
Three reasonable moments (the three that React Query brings as options):
- On returning to the tab (the user comes back to the browser after being in another app). It covers changes that happened while they weren't looking: prices that changed, sold-out products. It's "I take a new snapshot on looking at the wall again".
- After a mutation (after buying, revalidate the stock; after leaving a review, revalidate the reviews). It covers the change you yourself caused, so the UI reflects it. (It's the write → invalidate → refetch cycle of module 6.)
- Every so often (periodic revalidation), useful for data that changes fast and that the user looks at for a long time —a stock in a flash sale—. It covers continuous changes by other users.
The deep idea: the copy always can age, so "when to revalidate" is a design policy, not an automatic effect. useEffect(fetch, []) has none; a layer brings them ready (refetchOnWindowFocus, invalidateQueries, staleTime).
Exercise 3 — Why the manual guard doesn't scale. A colleague says: "the race is fixed with the ignore flag; I put it in the three components that do search and I'm done". Explain why that solution, although correct, doesn't scale, and what replaces it.
See solution
The ignore flag is correct, but it's a responsibility that falls on each component that does a value-dependent fetch: you have to write the flag, the if (!ignore) check and the cleanup, without forgetting any part, in the three search components… and in the next one someone adds, and in the autocomplete one, and in the filters one. It's a promise of "I'll remember to put it always", and sooner or later a new component is left without it and the race reappears. Besides, the ignore flag only cures the race: that component still has no cache, no dedup, no revalidation and its loading/error by hand.
What replaces it is a server-state layer that discards the old responses for you, in a single tested place, for all the queries —besides giving you cache, dedup, revalidation and loading states—. You declare useQuery(['search', query], ...) and the layer takes care of only the current query's response painting. The "when" stops being your problem per component and becomes the layer's responsibility, once. That layer is React Query (M5).
Summary and next step
In this lesson you measured the two "when" ills of manual fetching. The race condition: two searches ("mou", "mouse") whose responses arrive out of order make the naive version paint the old one ("mou") under the new text ("mouse"); the cure is an increasing id that discards the responses of already-superseded requests —the loose end react-fundamentals M6 left—. The lack of revalidation: useEffect(fetch, []) runs once, so the copy stays stale forever when the backend changes; the cure is a policy of when to request again. You anchored them with two letters out of order and the snapshot nobody refreshes, and you saw why handling the "when" by hand in each component —though correct— doesn't scale.
Before moving on you should be able to: explain why out-of-order responses paint the old one and how an increasing id cures it; recognize that [] means "once and never again"; design a revalidation policy; and justify why the per-component ignore flag doesn't scale.
Lesson 6 measures the fifth and last ill of manual fetching: the loading/error state repeated in each component. Since requesting is asynchronous and fallible (lesson 3), each component has to re-implement the loading → success | error machine —three useState, a useEffect—, and you'll see something worse: since they're independent copies, three components can be in different states for the same data at once (one loading, one in error, one with data), producing an incoherent UI. The last ill before the synthesis.
Resources
- React, "You Might Not Need an Effect" (section "Fetching data") — react.dev/learn/you-might-not-need-an-effect#fetching-data. The official doc that shows the
ignore flagfor the race and explains why rawuseEffectis fragile for fetching —M6's loose end, closed here—. In English. - TkDodo, "Why You Want React Query" — tkdodo.eu/blog/why-you-want-react-query. It includes the race conditions and the revalidation among the things you'd have to handle by hand and that the layer solves. In English.
- TanStack Query, "Important Defaults" — tanstack.com/query/latest/docs/framework/react/guides/important-defaults. How React Query revalidates by default (on focus, on reconnect) and what
staleTimeis —the "when" policy the[]doesn't have—. In English. - TkDodo, "React Query as a State Manager" — tkdodo.eu/blog/react-query-as-a-state-manager. Why revalidation (stale-while-revalidate) is the correct model for server state, instead of the single
fetchon mount. In English.