Module 1: The Four Kinds Of State

The misclassified server state bug

Overview

You already have the four boxes. This lesson is the module's thesis, measured firsthand: most "state management" bugs are really misclassified server state. In lesson 4 you saw one consequence —the manual copy that goes stale—; here we see the worst of all, the one that appears in real apps and drives teams crazy: when you treat server data as if it were yours, you end up with several copies of the same truth in different components, and those copies diverge. The result on screen is surreal: the same product shows two prices at once —one in the list, another in the detail panel— because each component stored its own copy and only one updated. It isn't a React bug nor a bug in your render logic; it's a classification error. And the cure isn't "more useEffect to sync": it's to stop having copies. A single truth in cache, read by all.

Connection with the module. The whole module points here. We classify to choose the right tool, yes, but above all to not fall into this bug. The decision rule puts "is the truth the backend's?" first precisely to catch remote data before inertia sends it to useState (local) or a store (global) —the two ways of "treating it as your own"—. This lesson demonstrates, by executing the bug and its cure, why that first question is worth gold. The tool that implements the cure —a cache by queryKey, with invalidation— is React Query, from modules 4 to 6 of this guide; here you see the problem it solves in its sharpest form, so that when you reach the tool you know exactly what it's saving you from.

An analogy: two people writing the balance in their notebook

Go back to the bank from lesson 4, but now with two people. You and your partner share an account. Each one, instead of opening the bank app when they need the balance, writes it in their own notebook the first time and treats it as the truth. On Monday both write down "$1,000". On Tuesday you pay $300 with the card; the bank now says "$700". You, who paid, update your notebook to "$700". But your partner's notebook still says "$1,000" —she didn't find out—. Now there are two contradictory truths about the same account: your notebook says $700, hers $1,000, and the bank (the real truth) says $700. When they compare notebooks, it'll be a disaster: which do they believe?

That's the bug, exactly. The two notebooks are two components, each with its copy (useState) of the same server data. The payment is a mutation that updated one copy and not the other. And the underlying problem isn't that one is "out of date": it's that they never should have had notebooks. If they both checked the bank (a single source) every time, it would be impossible for them to disagree. The cure isn't "sync your notebooks more carefully" (that's the useEffect people write to patch the bug); the cure is throw out the notebooks and have everyone read from the bank. In React: a single shared cache, not a copy per component.

How the bug is born: copy and sync by hand

The bug almost always starts with good intentions and the pattern react-fundamentals taught. Each component that needs a piece of server data does the natural thing: it requests it and stores it in its state.

// ProductList.jsx  — its own copy of the product
function ProductList() {
  const [product, setProduct] = useState(null);
  useEffect(() => { fetchProduct('p2').then(setProduct); }, []); // copy #1
  // ...shows product.priceCents
}

// ProductDetailPanel.jsx  — ANOTHER copy of the SAME product
function ProductDetailPanel() {
  const [product, setProduct] = useState(null);
  useEffect(() => { fetchProduct('p2').then(setProduct); }, []); // copy #2
  // ...shows product.priceCents
}

Each component, on its own, has a copy of product p2. As long as nothing changes, both copies match and everything looks fine —the usual deception—. But as soon as one of the copies updates (a mutation, a refetch, whatever) and the other doesn't, they diverge. And the "solution" many people apply —passing the product through props from a parent, or syncing the copies with another useEffect— is throwing on more fuel: more places where the same truth lives duplicated, more opportunities to desync. The problem isn't how you sync the copies; it's that you have copies. Let's measure both the bug and the cure.

Worked example: two copies that diverge vs. a single truth

We model in Node a backend with one product (the source of truth) and two scenarios. In the first (BAD), two components each have their copy (useState) and only one updates when the backend changes. In the second (GOOD), there's a single cache by queryKey that both read, and invalidating once updates the only copy:

// THE THESIS: most "state management" bugs are MISCLASSIFIED server
// state. If you treat it as yours, you end up with SEVERAL copies of the same
// truth in different components, and those copies DIVERGE.

// The backend: the single source of truth.
const backend = {
  product: { id: 'p2', name: 'Mechanical Keyboard', priceCents: 8900 },
  fetch() { return { ...this.product }; },
  applySale() { this.product.priceCents = 6900; },
};
const price = (p) => '$' + (p.priceCents / 100).toFixed(2);

console.log('=== The misclassified server state bug ===\n');

// ---- BAD: each component copies the server data to ITS own state ----
console.log('BAD: two components, each with ITS copy (useState) of the same product:');
let listCopy = backend.fetch();     // ProductList: its copy in useState
let detailCopy = backend.fetch();   // ProductDetail: its copy in useState
console.log('   ProductList  shows: ' + price(listCopy));
console.log('   ProductDetail shows: ' + price(detailCopy));

console.log('\n>>> The price drops in the backend to $69.00. The detail panel re-requests;');
console.log('    the list does not. Now there are TWO prices for the SAME product on screen:\n');
backend.applySale();
detailCopy = backend.fetch(); // only the detail asked again
console.log('   ProductList  shows: ' + price(listCopy) + '   <- STALE (old copy)');
console.log('   ProductDetail shows: ' + price(detailCopy) + '   <- fresh');
console.log('   >> the two copies DIVERGE: the same keyboard costs two things at once.');

// ---- GOOD: a single "server" box, one cache shared by queryKey ----
console.log('\nGOOD: a single cache (queryKey), read by both; invalidate -> both fresh:');
const cache = new Map();
const KEY = 'product:p2';
function readCache() { return cache.get(KEY); }
function revalidate() { cache.set(KEY, backend.fetch()); } // asks the backend again
function invalidate() { revalidate(); }                    // mark old == ask again

revalidate();
console.log('   ProductList  reads from cache: ' + price(readCache()));
console.log('   ProductDetail reads from cache: ' + price(readCache()));

console.log('\n>>> The price rises in the backend to $75.00 and we invalidate the queryKey once:\n');
backend.product.priceCents = 7500;
invalidate(); // a single invalidation updates the only copy
console.log('   ProductList  reads from cache: ' + price(readCache()) + '   <- fresh');
console.log('   ProductDetail reads from cache: ' + price(readCache()) + '   <- fresh');
console.log('   >> a single truth: impossible for them to diverge.');

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

=== The misclassified server state bug ===

BAD: two components, each with ITS copy (useState) of the same product:
   ProductList  shows: $89.00
   ProductDetail shows: $89.00

>>> The price drops in the backend to $69.00. The detail panel re-requests;
    the list does not. Now there are TWO prices for the SAME product on screen:

   ProductList  shows: $89.00   <- STALE (old copy)
   ProductDetail shows: $69.00   <- fresh
   >> the two copies DIVERGE: the same keyboard costs two things at once.

GOOD: a single cache (queryKey), read by both; invalidate -> both fresh:
   ProductList  reads from cache: $69.00
   ProductDetail reads from cache: $69.00

>>> The price rises in the backend to $75.00 and we invalidate the queryKey once:

   ProductList  reads from cache: $75.00   <- fresh
   ProductDetail reads from cache: $75.00   <- fresh
   >> a single truth: impossible for them to diverge.

Read the two halves, because together they're the module's thesis demonstrated:

BAD — the copies diverge. At first, ProductList and ProductDetail both show $89.00: they match, everything looks fine. Then the backend lowers the price to $69.00, and only the detail panel asks again. Result: the list shows $89.00 (its old copy) and the detail $69.00 (fresh). The same keyboard costs two things at once on the same screen. This is the bug teams chase for days thinking it's a problem of render, of props, of timing —when in reality it's that there are two sources of truth for data that has only one—. No matter how much they polish the syncing: as long as two copies exist, they can diverge.

GOOD — a single truth. Now there's one cache, indexed by a queryKey ('product:p2'), and both components read from it. There's no "list's copy" nor "detail's copy": there's one copy, shared. When the backend raises the price to $75.00, a single invalidation of that queryKey is enough for the only copy to refresh —and since both read from the same one, both see $75.00 instantly—. It's impossible for them to diverge, because there aren't two things that can disagree: there's a single one. That's the structural difference between the bug and the cure. The bug has N copies; the cure has 1.

Notice the role of the queryKey: it's the label that says "this is product p2". All components that want product p2 request by that same key and get the same cache entry. It's what turns "everyone with their copy" into "everyone with the same truth". When you reach React Query (M5), the queryKey will be central for this exact reason: it's the mechanism that guarantees a single copy per remote data.

Why useEffect to sync is throwing on fuel

The instinctive reaction on seeing the copies diverge is: "I'll sync them". And there the antipattern is born that TkDodo and the React docs call, bluntly, a mistake:

copy in ProductList   ──┐
                        ├── useEffect that "keeps them in sync" ──> more bugs
copy in ProductDetail ─┘

  the problem is NOT loose syncing;
  the problem is that there are TWO copies of ONE truth.
  the cure isn't to sync better: it's to have ONE single copy (cache by queryKey).

Each syncing useEffect you add is a promise that you'll remember to run it in all cases: when a copy changes, when new data arrives, when the user does a mutation. Sooner or later a case slips away from you, and the copies diverge again. It's an impossible battle to win because you attack the symptom (desynced copies) instead of the cause (that there are copies). The module's architecture lesson: don't sync copies of server data; eliminate the copies. A remote data lives in one cache, with one key, and everyone reads from there. That isn't given by useState + useEffect; it's given by a server-state library.

Common mistakes

Diagnosing the bug as a render problem. What happens: you see "the same product with two prices" and suspect the list's keys, a memo, the order of the renders. Why it happens: the symptom is visual, so the cause is sought in the visual layer. How to detect it: you spend hours in the render and the bug reappears; and you'd notice, if you looked, that there are two useState/fetch for the same data. How to fix it: the bug isn't render, it's classification —a piece of server data treated as your own, duplicated—. The cure is in the data layer (a single cache), not the render layer.

"Fixing it" by lifting the state to a common parent. What happens: the product's copy is lifted to the common ancestor and passed through props to the list and the detail, believing that this way there's "a single copy". Why it happens: it's react-fundamentals' technique for sharing client state. How to detect it: it works for these two components, but as soon as a third (on another page, without that ancestor) needs the same product, a new copy appears again and the bug returns. How to fix it: server state is not shared by lifting it in the component tree —that's for client state—; it's shared in a global cache by queryKey that any component, anywhere in the tree, queries with the same key. The remote truth lives outside the tree, not in an ancestor of it.

Confusing "it looks fine in development" with "it's fine". What happens: the bug doesn't appear in the demo (the data doesn't change, or there's a single component showing the data), and it's taken as good. Why it happens: the copies only diverge when something changes and more than one place shows the data —conditions that sometimes don't occur until production—. How to detect it: intermittent reports of "I saw a different price in two spots", impossible to reproduce at will. How to fix it: don't wait for the bug to appear; classify from the start. If the data is the server's, it goes in a single cache, even if today only one component shows it —tomorrow it'll be two, and then they don't diverge because there were never copies—.

Exercises

Exercise 1 — Find the copies. In a Mercado app, the header badge shows "3 products on sale" and a section of the home shows "Sale (3)". A user reports that sometimes the badge says 3 and the section says 2, for the same sales. Diagnose: which state box is misclassified, how many copies are there, and why do they diverge? What's the cure?

See solution
  • Misclassified box: server state. "The sales" are data whose truth lives in the backend; the badge and the home's section each did their own fetch and stored their copy.
  • How many copies: two —one in the badge's component, another in the section's—.
  • Why they diverge: when the sales change in the backend (one comes in or out), one component asks again and the other doesn't (or they do at different times), so their copies end up with different counts. It's the example's bug, with "number of sales" instead of "price".
  • The cure: a single cache by queryKey (for example 'deals') that both components read. On invalidating that key when the sales change, the only copy refreshes and both see the same number —impossible for them to disagree—. It isn't fixed by syncing the two fetch; it's fixed by eliminating one of the copies (having a single one).

Exercise 2 — Why useEffect doesn't save you. A colleague proposes fixing the example's bug by adding a useEffect in ProductList that "listens" for when ProductDetail updates its copy and syncs it. Explain why that solution is doomed, using the notebooks analogy. What should be done instead?

See solution

It's doomed because it attacks the symptom, not the cause. In the analogy: it's like asking the two people that, every time one writes something in their notebook, they notify the other to copy the change. It works if they never forget —but they will forget—: there'll be a payment one made and didn't communicate, a refetch that updated one copy and didn't trigger the notice, a case the useEffect didn't contemplate. Each new scenario is another chance for the notebooks to disagree. As long as two copies exist, perfect syncing is a promise impossible to keep in all cases.

Instead you have to throw out the notebooks: neither of the two components should have its own copy, and both should read from a single cache by queryKey (check the bank directly). With a single copy, there's nothing to sync —what's unique can't diverge—. The rule: don't sync copies of server state; eliminate them.

Exercise 3 — The queryKey as identity. In the example, the cure used the key 'product:p2'. Explain what role that key plays so that "everyone with their copy" becomes "everyone with the same truth". Then, if Mercado shows product p2 in the list, in the detail and in a "related" carousel, how many cache entries are there for p2 and why does that avoid the bug?

See solution

The queryKey is the identity of the remote data: 'product:p2' means "product p2, whoever requests it". Any component that wants that product requests by that same key and gets the same entry from the cache. It's what turns separate requests into a single shared truth: the key is the meeting point. Without it, each fetch would produce a loose piece of data, unrelated to the others (copies). With it, all the fetch of p2 point to the same place.

With the list, the detail and the carousel showing p2, there's a single cache entry for p2 (the one for the key 'product:p2'), not three. The three components read it. That's why the bug can't appear: when p2 changes and that single key is invalidated, the only entry refreshes and the three components —which read from it— see the same price at once. A thousand components showing p2 would still share one copy. That per-key uniqueness is exactly what makes "how many components use it" stop mattering for server state: however many use it, there's a single truth.

Summary and next step

In this lesson you measured the module's thesis: most "state management" bugs are misclassified server state. You executed the bug in its sharpest form —two components with their own copy (useState) of the same product, that diverge when the backend changes: the same keyboard with two prices at once— and its cure —a single cache by queryKey that both read, where one invalidation refreshes the only copy and it's impossible for them to disagree—. You understood why the syncing useEffect is throwing on fuel (it attacks the symptom, not the cause: that copies exist), and why lifting the state to a parent doesn't cure the server bug (the remote truth lives outside the tree, not in an ancestor). The queryKey as the data's identity is what turns "everyone with their copy" into "everyone with a single truth".

Before moving on you should be able to: explain why duplicating server data produces copies that diverge; diagnose the bug as a classification error (not render); rule out the false cures (syncing with useEffect, lifting to the parent); and justify why a cache by queryKey makes it irrelevant how many components use the data.

Lesson 7 turns the whole module into a procedure: the decision rule as a tree of four questions, executed with its reasoning trace piece by piece, and the box → tool map that anticipates modules 2 through 7. With it you'll close the criterion: not only will you know the four boxes, you'll know how to classify systematically, before touching any tool.

Resources