Frontend Architecture
State shape: the design decision before any library
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:
- 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.
- 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.
- 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.
| Value | Stored as state? | Why |
|---|---|---|
results from an API | No — server cache | Owned by the server; needs staleness + refetch, not useState |
count = results.length | No — derived | Computable during render; storing it invites drift |
isModalOpen | Yes — local UI state | Pure client concern, read by one subtree → colocate |
selectedId | Yes — but store the id, not the object | The 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 state →
useState(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
useStatemeans 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.
An editor holds threads → posts → users on the client, and users get renamed and edited constantly. Pick the client-side shape.
A list of products is fetched from an API and shown in three components. Where should it live?
You have results: Item[] in state and want to show how many there are. What's the senior move?
Order the questions a senior asks to decide where a value belongs:
- 1 Is it derivable from existing state? If yes, don't store it — compute during render
- 2 If it is state: is it server-owned (fetched) or client-owned (UI/draft/selection)?
- 3 Server-owned → a cache library (React Query/SWR), keyed by request
- 4 Client-owned and shareable/bookmarkable → the URL
- 5 Client-owned and private → lowest common ancestor of its readers (colocate)
- 01Explain to a teammate why storing count alongside a results array is a bug waiting to happen, and what to do instead.
- 02Why is 'server cache vs client state' the most important shape question, and how does it change your tooling?
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.