awesome-everything RU
↑ Back to the climb

Frontend Architecture

State shape: the design decision before any library

Crux Most ''''state bugs'''' are shape bugs — a value stored that should be derived, or server cache treated as client state. The shape decides your re-render blast radius and which bugs are even possible.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at junior altitude — the surface
◷ 16 min

A filter UI ships. Users report the count badge is wrong: “127 results” but the list shows 9. The fix is not a render bug — someone stored count in useState next to the results array. One setResults updated; the other did not. Two values that should have been one. The badge had been lying for three weeks before anyone filed it.

Shape is a decision, not a default

Before you reach for Redux, Zustand, Jotai, or React Query, you make a quieter decision that matters more: what is a piece of state, where does it live, and is it the source of truth or a copy of one? Get the shape right and most state-management “problems” never appear. Get it wrong and no library saves you — you just spread the same drift across a nicer API.

Three questions decide the shape of any value:

  1. Is it state at all, or is it derived? If you can compute it from existing state during render, it is not state. Storing it creates a second source of truth that will drift.
  2. Whose state is it — client or server? A list of orders fetched from an API is server cache, not client state. Modeling it as client state is the most common architectural mistake in frontend apps.
  3. Who reads it? That answer — one component, a subtree, or the whole app — sets where it lives and how big your re-render blast radius is.

The derived-state trap

The badge bug above is the canonical failure. count is results.length. The moment you store it separately, you own an invariant the framework was happy to maintain for free: every code path that changes results must also change count. Miss one path — an error handler, an optimistic update, a websocket patch — and the two drift. The UI shows a value that was true at some past render and never got corrected.

The senior reflex: if you can derive it, derive it. Compute during render; reach for useMemo only when the computation is measurably expensive, not by default. Memoization is a performance tool, not a correctness tool — adding it does not make derived-vs-stored a wash, because a stored copy is still a second source of truth even when memoized.

ValueStored as state?Why
results from an APINo — server cacheOwned by the server; needs staleness + refetch, not useState
count = results.lengthNo — derivedComputable during render; storing it invites drift
isModalOpenYes — local UI statePure client concern, read by one subtree → colocate
selectedIdYes — but store the id, not the objectThe object is derivable from id + the cache; the id is the real state

Server cache is not client state

The single biggest shape mistake is loading server data into useState/Redux and treating it as if you own it. You don’t. Server data has properties client state never has: it goes stale, it can be refetched, it needs deduping across components, it has loading and error states, it can be invalidated by a mutation elsewhere. When you model it as plain client state, you hand-roll all of that — usually badly. This is exactly why React Query / TanStack Query, SWR, and RTK Query exist: they are server-cache libraries, not state libraries, and the distinction is the whole point.

The practical split a senior draws:

  • Server cache → React Query / SWR / RTK Query. Keyed by request, with staleness, refetch, dedupe.
  • Client stateuseState (local), Context or Zustand/Jotai (shared). Things the server never knew: which tab is open, draft form input, selection.
  • URL → the source of truth for anything shareable or bookmarkable: filters, pagination, the open detail id. Storing those only in useState means a refresh loses them and a copied link is dead.

Colocation sets the blast radius

State lives at the lowest common ancestor of everything that reads it — and no higher. Hoisting a value to a global store “so it’s available” is the most common cause of app-wide re-render storms: every component subscribed to that store re-renders when it changes, even ones that never read the changed slice. Kent C. Dodds’ point is blunt — moving state down to where it’s used makes the app faster, because React only re-renders the subtree that owns it.

The cost is real at scale. A global store holding high-frequency state (mouse position, a live-updating counter, form keystrokes) re-renders every subscriber on every change unless you carefully select slices. Atom libraries (Jotai, Recoil) exist precisely to shrink that blast radius to the components reading a specific atom. But the cheapest fix is usually structural: don’t hoist what one subtree owns.

Why this works

“Lift state up” (the React docs phrase) is correct but routinely over-applied. Lift to the lowest common ancestor, not to the top. Lifting to the root is how a tooltip’s open/closed state ends up re-rendering a dashboard. The mirror-image rule — “push state down” — is what colocation actually means in practice.

Normalization: the tradeoff for relational client state

When you do hold relational data in a client store (an offline-first app, an editor with local entities), shape it the way a database would: a flat map keyed by id, not nested arrays. { users: { byId: {...}, allIds: [...] } } instead of users nested inside posts nested inside threads.

The tradeoff is concrete. Nested shape makes reads ergonomic (post.author.name) but updates a nightmare: change one user’s name and you must find and rewrite every copy embedded across the tree — and miss one, and you get drift again, now at scale. Normalized shape makes updates O(1) (write one entry in byId) at the cost of joining at read time (byId[post.authorId].name). For anything you mutate frequently, normalize. Redux Toolkit’s createEntityAdapter exists to make this default. For read-mostly server data, don’t bother — let the cache library hold the raw response.

Pick the best fit

An editor holds threads → posts → users on the client, and users get renamed and edited constantly. Pick the client-side shape.

Quiz

A list of products is fetched from an API and shown in three components. Where should it live?

Quiz

You have results: Item[] in state and want to show how many there are. What's the senior move?

Order the steps

Order the questions a senior asks to decide where a value belongs:

  1. 1 Is it derivable from existing state? If yes, don't store it — compute during render
  2. 2 If it is state: is it server-owned (fetched) or client-owned (UI/draft/selection)?
  3. 3 Server-owned → a cache library (React Query/SWR), keyed by request
  4. 4 Client-owned and shareable/bookmarkable → the URL
  5. 5 Client-owned and private → lowest common ancestor of its readers (colocate)
Recall before you leave
  1. 01
    Explain to a teammate why storing count alongside a results array is a bug waiting to happen, and what to do instead.
  2. 02
    Why is 'server cache vs client state' the most important shape question, and how does it change your tooling?
Recap

The shape of state is decided before any library, and it decides which bugs are possible. Anything derivable should be derived during render, never stored, or it becomes a second source of truth that drifts. Fetched data is server cache — give it to a cache library that handles staleness, refetch, dedupe, and invalidation, instead of modeling it as client state by hand. Shareable view-state belongs in the URL; the rest is colocated at the lowest common ancestor of its readers, which keeps the re-render blast radius small. When you genuinely hold relational data on the client, normalize it into a byId map so updates stay O(1) and can’t drift. Get the shape right and most “state-management problems” simply never appear.

Continue the climb ↑State shape: multiple-choice review
shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.