DataGENERALFRAMEWORK-SPECIFICSIMPLIFIED

Server State Is Not Your State

It has freshness, a cache, invalidation, refetching, synchronisation and an authority that lives somewhere else. Copy it into component state and it starts diverging immediately.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

Which of the values in my components are actually owned by a server, and what changes the moment I admit that?

The user intent

A person expects the numbers on their screen to be true. Not true five minutes ago in one tab and true now in another — true, everywhere they look.

The obvious build

Fetch it, put it in state, render it. It is a value the component needs, and state is where values the component needs go. Every tutorial in the ecosystem is shaped exactly like this.

Why it breaks

The value has a source of truth that is not you. The moment it is copied into a component, that copy is a *cache* — an unmanaged one, with no freshness, no invalidation and no eviction (The Client Cache Model).

How it breaks in a real browser
  • The value has a source of truth that is not you. The moment it is copied into a component, that copy is a *cache* — an unmanaged one, with no freshness, no invalidation and no eviction (The Client Cache Model).
  • Two components fetch it and now there are two copies. A mutation updates one; the other renders yesterday's number until it happens to remount (Five Components, One Request).
  • Unmounting throws it away. Navigate to a detail view and back, and a value that has not changed in a week is fetched again — with a spinner, on a slow connection, for nothing.
  • It goes stale silently. A dashboard left open for an hour shows an hour-old snapshot with total confidence and no indication that anything might have moved (Long-Lived Clients and Version Skew).
  • Other tabs, other devices and other people are changing it. Component state has no concept of an update it did not initiate (Auth Across Tabs).
  • Every screen re-implements the same four things — loading, error, deduplication, refetch — slightly differently, and no two of them are wrong in the same way.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • Client state is authored in the browser and has no authority elsewhere: which tab is open, what is typed in a filter, whether a menu is expanded. It is created here, it is correct by definition, and losing it costs the user a little effort (The Seven Kinds of State).
  • Server state is a *copy* of something owned elsewhere. It arrives already slightly out of date, it can be changed by parties you cannot observe, and it is only ever as correct as its last synchronisation.
  • That difference gives server state six properties client state does not have: freshness (how old is this), caching (who keeps it), invalidation (what makes it wrong), refetching (how it is renewed), synchronisation (who else changed it), and an authority that can disagree with you.
  • A cache needs an identity for what it holds. That is the query key, and it must include every input that changes the answer — endpoint, parameters, filters, and the identity of the user asking (Query Keys and Invalidation).
  • Derived values should stay derived. Storing a filtered, sorted, formatted copy alongside the server data creates a second thing to invalidate, and it is always the one that gets forgotten (Derived State).
  • The URL is the third category and the one most often forgotten: which record, which page, which filters. It belongs in the address bar so it survives a reload, a share and a back button (The URL Is Application State).
  • Optimistic updates are the deliberate, temporary act of letting the client disagree with the server — which only works if there is an explicit reconciliation path when the server answers (Optimistic UI).

What this makes the browser do

And which of it is avoidable.

  • Duplicate copies mean duplicate parses, duplicate objects and duplicate renders for one logical value (What a Component Costs to Render).
  • A shared cache gives one referential identity, which is what makes memoisation and key-based reconciliation actually skip work rather than merely appear to (Memoization).
  • A cache that never evicts is a leak. Long sessions across many routes accumulate results nobody will read again (Memory Leaks).
  • Background refetch on focus or reconnect is real work at a moment the user may be interacting; scheduling it badly costs input responsiveness (Yielding and Scheduling).
  • Avoidable: re-fetching and re-parsing on every remount, which is the default behaviour of the naive version and the largest single saving from adopting a cache.

Where the authority lives

The diagram is the argument. There is one authoritative value, and everything on the client is a copy at some remove from it — a copy that other tabs, other users and background jobs are all editing without asking you. The question a lesson like this exists to install is not "where do I put this value" but "who is allowed to be wrong about it".

Look at the two edges into component copy. One is the managed path, where the copy is a subscription to a cache that knows how old it is and what makes it wrong. The other is useState, where the copy has no age, no owner and no way to be told that the world moved. Both render identically on the first paint. Only one of them is still right ten minutes later.

One authority, several copies
mutate, unobservedfetch → stamped with a key and an agesubscribe: one copy, one identityfetch → forgotten on unmountdiverges silentlydefines the keyauthored here; cannot be staleinvalidate → refetchOther tabs, users, jobsURL — which record, page, filterClient state — menu open, draft textSource of truthAPIComponent copy in useStateKeyed client cache (age, key, invalidation)What the user believes
UserLLMAgentToolDataDecisionHumanGuardrail

The moment the copy forks

The pattern below is written thousands of times a day and it is almost always a mistake. Fetch, copy into local state, work with the local copy. It is not that the author did not think — it is that useState is the tool everyone reaches for, and nothing about the API hints that this particular value has an owner elsewhere.

What makes it hard to see is that it works. It renders, it updates, the tests pass. The divergence needs two things to become visible — a second view of the same record, or the passage of time — and neither is present while the code is being written.

The fork, and the subscription
1// The fork. This copy has no age, no key, and no owner.
2function OrderTotal({ id }: { id: string }) {
3 const [order, setOrder] = useState<Order | null>(null)
4
5 useEffect(() => {
6 fetch(`/api/orders/${id}`).then((r) => r.json()).then(setOrder)
7 }, [id])
8
9 // A second component doing this has a SECOND copy.
10 // A mutation elsewhere updates neither.
11 // Unmounting throws it away and refetches on return.
12 // An hour later it is an hour old and says so nowhere.
13 return <p>{order ? format(order.total) : '…'}</p>
14}
15
16// The subscription. One entry, one identity, one age.
17function OrderTotal({ id }: { id: string }) {
18 const { data, status, isStale } = useQuery({
19 key: ['order', id], // identity: everything that changes the answer
20 fetch: (signal) => getOrder(id, signal),
21 freshFor: ORDER_FRESHNESS, // an explicit policy, per query, not a global default
22 })
23
24 if (status === 'pending') return <p role="status">Loading total…</p>
25 if (status === 'error') return <p role="alert">Could not load this order.</p>
26 return <p aria-describedby={isStale ? 'refreshing' : undefined}>{format(data.total)}</p>
27}
28
29// The one legitimate fork: an edit in progress.
30// Deliberate, bounded, and reconciled on submit.
31const [draft, setDraft] = useState(() => toDraft(data))

The second version is not longer because of the library. It is longer because it says out loud what the first version left unanswered: what identifies this data, how long it may be trusted, and what the user sees in each of the states the first version does not have.

Three homes, and how to tell them apart

Most state bugs are this question never asked. Run every value through it once — it takes seconds and it is the difference between an application with a state architecture and one with a hundred local decisions (Who Owns This State?).

The tie-breaker for the hard cases is the reload test: if the user refreshes the page, what *should* still be true? If the answer is "it comes back from the server", it is server state. If it is "it should be in the address bar", it is a URL concern. If it is "it can reasonably be lost", it is client state.

Where does this value live?

You have a value the UI needs. Which of the three homes owns it?

Client state — component or local store

when Authored in the browser, no authority elsewhere: a menu's open state, a hover, an unsent draft, a wizard step.

cost Lost on reload, and invisible to other tabs. Both are usually fine, and both should be a decision rather than an accident (The Seven Kinds of State).

URL — the address bar

when It determines what the page is showing: which record, which page, which filters, which tab. Anything a user would reasonably want to share, bookmark or reach with the back button.

cost A public, shared surface with a length limit, and every change is a history entry unless you replace instead of push (The URL Is Application State).

Server state — a keyed cache

when The authority is a server. Anything fetched, anything another user can change, anything with a "last updated".

cost Keys, freshness, invalidation, eviction and session scoping — a real subsystem, whether you write it or adopt it (The Client Cache Model).

Form state — a deliberate fork

when The user is editing server data. The draft diverges on purpose, from the moment they start until they submit or discard.

cost You now own the reconciliation: what happens if the underlying record changes mid-edit is a product decision, not a technical one (Form State Is a Draft).

Persistent client state — storage

when It must survive a reload but has no server authority: a theme, a dismissed banner, a column layout.

cost Synchronous storage APIs block the main thread, quotas are finite, and anything put there is readable by any script on the origin (Persistent Client State).

How to build it

Most important first.

  • Classify every value before deciding where it lives: authored here, owned by a server, or an address. Three categories, three different homes, and most state bugs are this question left unanswered (Who Owns This State?).
  • Keep server data in one keyed cache, not in components. Components subscribe; they do not own (The Client Cache Model).
  • Never copy fetched data into useState to "work with it locally". That is the exact moment divergence begins, and the reasons for doing it — a local edit, a formatted view — each have a better answer (Derived State).
  • Make the freshness policy explicit per query. A currency rate, a user profile and an order status have wildly different tolerances, and a single global default gets all three wrong (Stale-While-Revalidate).
  • Invalidate by key after mutations, rather than hand-patching every place the value appears. Patching is faster and is how the copies drift (Rollback and Reconciliation).
  • Design for the update you did not initiate: refetch on window focus, on reconnect, and on an event from a live channel (Resynchronisation After a Gap).
  • Editing is the legitimate exception. A form takes a snapshot on purpose, and the fork is deliberate, bounded and reconciled on submit (Form State Is a Draft).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Background updates change content without a user action. Something must be announced — but politely, and only when the change is meaningful, because a live region that fires on every poll is unusable (Live Regions and Announcement).
  • Never move focus because data refreshed. The user did not ask for anything; moving focus during a background update is an interruption with no cause the user can perceive (Focus Management).
  • Refreshing a list under a screen-reader user who is navigating it resets their position. Prefer merging into a keyed list over replacing it, so the reading position survives (Reconciliation and Keys).
  • Optimistic UI shows a value that may be revoked. When it is, the reversal must be announced — a row that silently changes back is invisible to anyone not watching that exact pixel (Optimistic UI).
  • Stale data with a visible "updating" hint needs a text equivalent, not just a spinning icon in the corner (Semantics Before ARIA).

What can go wrong

Failure modes
  • Two views of the same record showing different values, which the user notices before you do because they have both on screen.
  • A mutation that updates local state and never invalidates the list, so the row is correct on the detail page and stale on the list behind it.
  • A cache key that omits the user, so after switching accounts the previous account's data is rendered from cache — a performance optimisation that became a data-exposure bug (Authorization-Aware UI).
  • A form initialised from a prop that keeps changing underneath the user, so a background refetch overwrites what they were typing (Controlled vs Uncontrolled Inputs).
  • Server data mirrored into a global store "so everything can see it", which produces a second cache with none of the first one's freshness machinery (Prop Drilling, Context and Global State).
  • The mitigation failing: a cache with a generous freshness window and no invalidation on mutation, so the application is now confidently wrong for longer than it used to be (Cache Invalidation, Stampedes and Hot Keys in Backend).
  • Refetch-on-focus applied to a form-heavy screen, so every alt-tab back into the browser discards work in progress.
What can arrive out of order
  • A background refetch can resolve while an optimistic update is pending, overwriting the optimistic value with a server response that predates the mutation (Optimistic UI).
  • Two mutations to the same record from two tabs are applied in server order, which is not necessarily the order either user perceived (The Lost Update, Step by Step in API Design).
  • An invalidation and an in-flight fetch for the same key race: the response can arrive after the invalidation and repopulate the cache with data that was already known to be stale (Query Keys and Invalidation).
  • A response for a previous user can arrive after a sign-in, writing another account's data into a freshly cleared cache (Session Expiry and the Refresh Race).
Security
  • Cached server data survives the session unless you clear it. Sign-out must clear the cache and the in-flight registry, or the next user of the browser sees the previous one's data (Session Expiry and the Refresh Race).
  • The cache key is an authorization boundary in practice. Any key not scoped to the identity of the requester will eventually serve one user's data to another (What the Frontend Is Responsible For in Auth).
  • Client state is never an authorization decision. A cached role: "admin" renders an admin UI; the server must still refuse every request that UI makes (Authorization-Aware UI).
  • Persisting server state to localStorage for offline use extends its lifetime and its exposure. It is now readable by any script on the origin and survives the session by design (Storage Security and Durability).
Misreads
  • "State is state." Client state has no authority elsewhere and cannot go stale. Server state is a copy of something you do not own, and every one of its properties follows from that.
  • "A global store solves this." A global store is a place to put values. It has no freshness, no invalidation and no refetch, so server data in it is the same unmanaged cache with wider reach (Prop Drilling, Context and Global State).
  • "If I refetch after every mutation I am consistent." You are consistent with what the server said at that moment. Other people are still changing it, which is why focus and reconnect refetching exist (Eventual Consistency in Practice in Backend).
  • "The cache is a performance feature." It is a *correctness* feature first: one copy with one identity is what stops two views of the same record disagreeing.
  • "Server components remove the problem." They move where the fetch happens and change which boundaries can refetch. The data is still a copy of something owned elsewhere, and it is still stale the moment it is rendered (Server Components).

Measuring it, and what changes in the field

How you would see this
  • Count requests per route transition. A cache that is working shows a sharp drop on repeat navigations; one that is not shows the same requests every time (Debugging the Network).
  • Inspect the cache directly — devtools for your data library, or the store in the console. "How old is this entry and what key is it under" is the question that resolves most staleness arguments (Debugging State).
  • Track how long entries live between refetches in the field. Freshness policies are usually set once from intuition and never revisited (Real User Monitoring).
  • Watch memory across a long session with many routes. A cache with no eviction has the same profile as a leak, because it is one (Debugging Memory).
Slow device, slow network, large data, old tab
  • On a slow network, caching is the difference between an application that feels instant on repeat navigation and one that shows a spinner every time.
  • On a long-lived tab, freshness dominates: most of what is on screen was fetched long ago, so refetch-on-focus and refetch-on-reconnect carry most of the correctness (Long-Lived Clients and Version Skew).
  • With several tabs open, the same data is cached independently per tab unless you synchronise deliberately — and the user considers all of them to be "the app" (Auth Across Tabs).
  • Offline, every cached value is stale by an unknown amount and every mutation is a promise you have not kept yet (The Offline Mutation Queue).
What this costs
  • A cache is a subsystem: keys, freshness, invalidation, eviction, session scoping. It replaces a small mistake repeated in fifty components with a large mechanism in one place, and the mechanism has to be understood by everyone.
  • Aggressive freshness policies mean more requests; relaxed ones mean confidently displaying old data. There is no setting that is right for every query, which is why the policy belongs per key rather than globally.
  • Background refetching costs battery, bandwidth and main-thread time, all at moments the user did not ask for anything (The Real Cost of JavaScript).
  • Adopting a data library moves a hard problem into a dependency. That is usually the right call and it is still a real cost: its cache semantics are now part of your architecture.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe distinction is architectural rather than technological: it holds identically in a framework with hooks, in a framework with signals, and in a page with no framework at all, because it follows from where the authority for a value lives rather than from how the value is stored.
  • FRAMEWORK-SPECIFICHow much of the machinery you get for free varies enormously — a data library supplies keys, freshness, deduplication and invalidation; a router with loaders supplies fetching and revalidation but not deduplication across routes; a bare hook supplies nothing. The properties of server state do not change, only who implements them.
  • SIMPLIFIEDTreating the categories as three clean buckets is a teaching model. Real values blur: a draft is client state derived from server state, a shopping basket may be either depending on whether it is persisted, and an auth token is server state you are forbidden to refetch on demand.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Distributed Systems — a browser holding a cached copy of a server-owned value is a replica, and every question about replicas applies: staleness bounds, read-your-writes, and what happens when two replicas disagree.
  • Software Design — "who owns this value" is the same question as "who owns this invariant", and answering it once per value is what stops a codebase accumulating a hundred inconsistent answers.