Module 4: Server State Is Different

The four properties of server state

Overview

You already know that server state isn't yours: it's a cache of a truth that lives in the backend. This lesson enumerates the four properties that follow from that fact and that no other state box has. They're the complete "kit" that makes server state need its own tool, and it's worth having them as a mental list, because each one explains a piece of React Query you'll see in M5-M6. The server copy goes stale (ages when the backend changes underneath), is shared (other users and processes mutate the same remote truth), is asynchronous (requesting it takes time: it goes through a loading state), and can fail (the network or the server break: you have to handle an error state). The four come from a single root —the truth is someone else's and you have to go fetch it over the network— and the four we're going to measure, not assert.

None of these four properties happens to client state, and that contrast is the best way to recognize them. The theme doesn't age on its own, nobody else moves it, changing it doesn't "load" and can't "fail". The cart (while it lives on the client) doesn't either. When you see a piece of state that has these four marks together, you know for certain you're facing server state —and that a useState isn't enough to manage it, because a useState doesn't know its value aged, doesn't know how to ask for it again, and doesn't have a "loading" nor an "error" state—.

Connection with the module. Lesson 2 fixed the nature (it isn't yours, it's a cache); this one enumerates the consequences of that nature. It's the "properties" lesson that gives precise vocabulary for the rest of the module: when lesson 5 talks about "the copy goes stale" or lesson 6 about "the loading/error state", you'll already know exactly what they are and where they come from. Each property, moreover, anticipates a feature of React Query (M5-M6): "stale" → staleTime; "revalidate" → refetch and invalidate; "shared" → a cache by queryKey; "asynchronous/fails" → isLoading/isError. Here we name and measure them; there they're implemented.

An analogy: the weather you check

Let's change the image to fix the properties well. Think about the weather. When you open a weather app and see "23°C, sunny", you're seeing a copy of a data whose truth you don't control: the real weather is produced by the atmosphere, and a weather service measures it and publishes it. You only check it. And that check has, exactly, the four properties of server state.

It goes stale: the weather changes on its own, without telling you. You check at 8:00 and it says "sunny"; at 11:00 it's raining, but your screen —if you didn't refresh it— still says "sunny". Your copy aged because the truth moved. You have to revalidate: the only way to know the current weather is to check again (pull to refresh the app). It's shared: the weather isn't yours; millions of people check it at once, and the truth is moved by something external (the atmosphere), not you. It's asynchronous and can fail: when you request the weather, the app takes a moment to bring it (a spinner: loading), and sometimes the service doesn't respond —no signal, server down— and you see an error with a "retry" button.

Compare it with the temperature you set on your thermostat: that truth is yours, doesn't change on its own, nobody else moves it, and adjusting it is instant and doesn't "fail". The weather is server state; the thermostat is client state. The four properties are the difference between checking something someone else controls and deciding something you control. Keep the weather image: every time you doubt whether a data is server's, ask yourself whether it's more like the weather (you check it, it changes on its own, it can fail) or the thermostat (you set it, it's yours).

Worked example: the four properties, one by one

We're going to execute the four. We model in Node a backend with one product (the source of truth) and trigger each property with a mini-scenario. For the first two (stale and shared) we'll see how the copy falls behind when the truth moves; for the last two (asynchronous and fails) we model the state machine of a request (idle → loading → success or → error), which is the real way a request is lived in the UI. First, how that machine looks in React —the state react-fundamentals made you manage by hand—:

// The state machine that EVERY request lives: idle -> loading -> success | error.
function useProduct(id) {
  const [status, setStatus] = useState('loading'); // starts loading
  const [data, setData]     = useState(null);
  const [error, setError]   = useState(null);
  useEffect(() => {
    fetch('/products/' + id)
      .then((r) => r.json())
      .then((d) => { setData(d); setStatus('success'); })
      .catch((e) => { setError(e.message); setStatus('error'); });
  }, [id]);
  return { status, data, error }; // the UI decides what to paint according to status
}

Notice that that machine —three pieces of state, a useEffect, a .then and a .catch— exists only because the data is asynchronous and fallible. Client state needs none of this. Now let's execute the four properties:

// The four properties that make server state different, each one MEASURED.
// backend = source of truth; the client has a cached copy.

const backend = {
  product: { id: 'p1', name: 'Wireless Mouse', priceCents: 2599, stock: 3 },
  fetch() { return { ...this.product }; },
};
const money = (c) => '$' + (c / 100).toFixed(2);

console.log('=== The 4 properties of server state ===\n');

// ---------- 1) GOES STALE: the truth moves, your copy ages ----------
console.log('1) STALE — your copy ages when the backend changes underneath');
let copy = backend.fetch();
console.log('   copy on mount:     ' + money(copy.priceCents));
backend.product.priceCents = 1999; // the backend changes
console.log('   backend now:       ' + money(backend.fetch().priceCents));
console.log('   your copy (no refetch): ' + money(copy.priceCents) + '   <- STALE\n');

// ---------- 2) SHARED: other users mutate the same truth ----------
console.log('2) SHARED — other users move the same remote truth');
copy = backend.fetch();
console.log('   your copy: stock = ' + copy.stock);
backend.product.stock -= 3; // other users buy the 3 units
console.log('   another user buys 3 -> backend: stock = ' + backend.fetch().stock);
console.log('   your copy (no refetch): stock = ' + copy.stock + '   <- you show "available" something sold out\n');

// ---------- 3) ASYNCHRONOUS: requesting it takes time -> loading ----------
// We model the state machine of a request: idle -> loading -> success
console.log('3) ASYNCHRONOUS — requesting it isn\'t instant: it goes through "loading"');
let req = { status: 'idle', data: null, error: null };
console.log('   status: ' + req.status);
req = { status: 'loading', data: null, error: null }; // the fetch fires
console.log('   status: ' + req.status + '   <- the UI shows a spinner');
req = { status: 'success', data: backend.fetch(), error: null }; // the response arrives
console.log('   status: ' + req.status + '   -> data.name = "' + req.data.name + '"\n');

// ---------- 4) CAN FAIL: the network goes down ----------
console.log('4) CAN FAIL — the network or the server can break: "error"');
let req2 = { status: 'loading', data: null, error: null };
console.log('   status: ' + req2.status);
req2 = { status: 'error', data: null, error: 'NetworkError: fetch failed' };
console.log('   status: ' + req2.status + '   -> error = "' + req2.error + '"');
console.log('   the UI must show "Retry", not crash.\n');

console.log('=== None of the 4 happens to client state ===');
console.log('   The theme doesn\'t go stale, no one else moves it, doesn\'t load, doesn\'t fail: its truth is yours.');
console.log('   These 4 properties are the reason the server needs its own tool.');

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

=== The 4 properties of server state ===

1) STALE — your copy ages when the backend changes underneath
   copy on mount:     $25.99
   backend now:       $19.99
   your copy (no refetch): $25.99   <- STALE

2) SHARED — other users move the same remote truth
   your copy: stock = 3
   another user buys 3 -> backend: stock = 0
   your copy (no refetch): stock = 3   <- you show "available" something sold out

3) ASYNCHRONOUS — requesting it isn't instant: it goes through "loading"
   status: idle
   status: loading   <- the UI shows a spinner
   status: success   -> data.name = "Wireless Mouse"

4) CAN FAIL — the network or the server can break: "error"
   status: loading
   status: error   -> error = "NetworkError: fetch failed"
   the UI must show "Retry", not crash.

=== None of the 4 happens to client state ===
   The theme doesn't go stale, no one else moves it, doesn't load, doesn't fail: its truth is yours.
   These 4 properties are the reason the server needs its own tool.

Review the four with the weather analogy:

1) Stale. Your copy said $25.99 on mount; the backend lowered the price to $19.99; your copy, without asking again, stayed at $25.99. It's the weather you checked at 8:00 and it still says "sunny" when it's already raining. The copy isn't broken; it's old, because the truth moved and it didn't find out.

2) Shared. Your copy said stock = 3; other users bought the three units and the backend went to stock = 0; your copy stayed at 3. This is the most dangerous one: you're showing "available" something that already sold out. The truth is shared —many mutate it at once—, so it ages extremely fast and without you doing anything. Nobody in your app touched the stock; someone else moved it.

3) Asynchronous. The request went through idle → loading → success. The loading isn't an ornament: it's a real state the UI has to go through, because requesting the network takes time. While it loads, you show a spinner; when it arrives, you show the data. Client state doesn't have this step —setState is instant, there's no "loading"—.

4) Can fail. The request went through loading → error. The network goes down, the server returns a 500, the user has no signal: the request fails, and your UI has to handle it —show "Retry", not a blank screen nor a crash—. Assigning a useState never "fails"; requesting a backend does. That's why server state always has three possible faces —loading, data, error—, and modeling them is part of managing it.

The closing says it: none of the four happens to the theme. That's the quick test. If a data goes stale, is shared and mutated by others, loads and can fail, it's server's. If none of that happens to it —it changes only when you decide, instantly, without failing—, it's client's.

How each property becomes a piece of React Query

It's no coincidence there are four properties: each one has a concrete answer in the tool you'll see in M5-M6. Keep this map; it's the bridge between "the problem" (this module) and "the solution" (the following ones):

server state property                  what React Query (M5-M6) gives it
──────────────────────────────────     ──────────────────────────────────────────
goes STALE                             staleTime: how long the copy is considered fresh
must REVALIDATE                        automatic refetch (on focus, on reconnect) + invalidate
is SHARED                              a cache by queryKey: one copy for everyone
is ASYNCHRONOUS (loading)              isLoading / isPending: loading state done
can FAIL (error)                       isError / error + retries: error state done

Read it backwards and you'll see why React Query looks the way it does: it isn't an arbitrary library with a thousand options, it's the answer point by point to the four properties of server state. staleTime exists because copies age. The cache by queryKey exists because the truth is shared. isLoading and isError exist because requesting is asynchronous and fallible. When you get there, you'll recognize each piece because you already suffered the property that justifies it.

Common mistakes

Handling only the "happy case" (ignoring loading and error). What happens: the component is written assuming the data "is already there", without contemplating "loading" nor "failed". Why it happens: treating the copy as a client useState, you forget that requesting it takes time and can break. How to detect it: blank screens while loading, or crashing (Cannot read property 'name' of null) when the fetch hasn't returned yet or failed. How to fix it: server state always has three faces —loading, data, error—, because it's asynchronous and fallible. Model the three. (React Query gives them done: isLoading, isError, data.)

Believing "just loaded" is "correct forever". What happens: since the data arrived on mount, it's assumed that it stays fine until the user reloads. Why it happens: it's forgotten that the truth is shared and changes underneath. How to detect it: bugs of "I saw available something that was no longer there", old prices, out-of-sync counters —the case of stock = 3 that was already 0—. How to fix it: the copy ages because others mutate the truth; it needs a revalidation policy (asking again at certain moments), not a single fetch. It's the weather: you have to re-check.

Confusing "loading" with "error" (or treating them as the same case). What happens: the same generic message is shown for "not here yet" and for "failed", or the user is left with no way to retry after an error. Why it happens: both "show no data", so they collapse into one. How to detect it: an infinite spinner when the request actually failed, or a "something went wrong" when it's actually just loading. How to fix it: they're different states —loading is transient and resolves on its own; error is terminal and needs an action (retry)—. The idle → loading → success | error machine separates them on purpose.

Exercises

Exercise 1 — Weather or thermostat? For each data, say whether it has the four properties of server state (like the weather) or none (like the thermostat), and name at least one property that gives it away: (a) the number of likes on a post; (b) the music player's volume the user adjusted; (c) a stock's price on the exchange; (d) whether the user has the side menu open.

See solution
  • (a) likes → weather (server). It goes stale (others are liking right now); it's shared (the truth is the backend's); requesting it loads and can fail. It ages extremely fast.
  • (b) volume → thermostat (client, local). The truth is the user's, in their app; it doesn't go stale, nobody else moves it, adjusting it is instant and doesn't fail.
  • (c) a stock's price → weather (server), extreme. It changes every second (stale almost instantly); shared by everyone; asynchronous and fallible. The most aggressive case of "you have to revalidate often".
  • (d) open side menu → thermostat (client, local). A single component's truth; it has none of the four properties.

Exercise 2 — Why the stock is the most dangerous. In the run, your copy showed stock = 3 when the backend already said 0. Explain which property caused this and why this concrete case can cost money or trust, more than an old price.

See solution

It was caused by the shared property: the stock is a remote truth that other users mutate all the time (each purchase lowers it). Your copy was made on mount (3), and meanwhile three people bought the three units, leaving the backend at 0 —but your copy didn't find out—.

It's more expensive than an old price because of what it causes downstream: if you show "available" something sold out, the user adds it to the cart, reaches the checkout, and there the purchase fails (or worse, it's confirmed and there's no product to ship). An old price is fixed by showing the correct one; an old stock generates impossible-to-fulfill orders, frustrated carts and distrust. That's why the stock usually needs the most aggressive revalidation of the whole app —it's re-checked very often, almost like a stock's price—.

Exercise 3 — Design the state machine. A component shows the user's profile, which comes from GET /me. Enumerate the states the request can go through and, for each one, what the UI should paint. Then say why client state (the theme) does not need this machine.

See solution

The states of GET /me, in possible order:

  • loading (or idle → loading): the request is in progress. The UI paints a spinner or a profile skeleton. There's no data yet, so it shouldn't try to read user.name.
  • success: the data arrived. The UI paints the profile (user.name, avatar, etc.).
  • error: the request failed (no network, 500, expired token). The UI paints a clear message with a "Retry" button, never a blank screen nor a crash.

The theme doesn't need this machine because its truth is yours and synchronous: changing it is a setState that happens instantly, in memory. There's no "while it arrives" (it doesn't travel over the network) nor a "failed" (assigning a variable can't fail). That's why client state is managed with a single value, and server state with a whole three-state machine. That machine is exactly what React Query gives you done (isLoading, isError, data) instead of you writing it in every component —you'll see it repeated by hand in lesson 6—.

Summary and next step

In this lesson you enumerated and measured the four properties that make server state different: it goes stale (your copy ages when the backend changes —the price that went to $19.99 and your copy stayed at $25.99—), it's shared (others mutate the truth —the stock that was already 0 while you showed 3—), it's asynchronous (it goes through loading —a spinner while it arrives—) and it can fail (it goes through error —"Retry", not a crash—). You anchored them with the weather —you check it, it changes on its own, everyone shares it, and sometimes the service doesn't respond— against the thermostat —your truth, instant, yours—. And you saw the map that turns each property into a piece of React Query: staleTime, the cache by queryKey, isLoading, isError.

Before moving on you should be able to: name the four properties and recognize which ones give a piece away as server's; distinguish loading from error as separate states; and explain why client state doesn't need the idle → loading → success | error machine.

Lesson 4 starts to measure the ills of manual fetching at scale, with the first two: no cache and no dedup. You're going to see, executed, how three components that show the same products make three requests to the same endpoint (because each useEffect requests on its own), and how two components that mount at once request the same data twice —and how a layer indexed by queryKey drops those numbers to one—. There the "shared" property you saw here becomes a concrete problem of duplicated requests.

Resources