frontend-state-architecture
Most frontend messes are state messes: server data copied into useState and going stale, everything crammed into one global store, or filters kept in component state so they vanish on refresh and can't be shared by URL. The fix is almost never a different library — it's putting each kind of state in the place that owns it. This skill is that decision.
The categories (put each piece in exactly one)
| Kind of state | Lives in | Examples | Owned by |
|---|---|---|---|
| Server cache | a data-fetching cache (React Query / SWR / RTK Query / Apollo) | the user list, a profile, anything from an API | the server; the client only caches it |
| URL state | the URL (query params / route) | current tab, filters, sort, pagination, search query, selected id | the address bar |
| Form state | a form library or local state | field values, validation, dirty/touched | the form, until submit |
| Client/UI state | local useState → lift only as needed |
modal open, hover, wizard step, optimistic toggles | the component(s) using it |
| Global client state | a small global store (Zustand/Redux/context) | auth session, theme, feature flags, cross-tree UI | the app shell |
The single most common mistake: treating server data as client state. It isn't yours — it's a cache of someone else's data. Put it in a cache library that handles staleness, refetch, dedup, and invalidation. Copying it into useState means you now own cache invalidation, and you will get it wrong.
Decision tree
- Does it come from the server? → server-cache library. Stop. Do not mirror it into
useState. - Should it survive refresh / be shareable by link / be back-button-navigable? → URL. Filters, tabs, sort, the open item — these belong in query params, not component state.
- Is it form input being edited? → form library (or local state for trivial forms) until submit; then it becomes a server mutation.
- Is it UI-only and used by one subtree? → local
useState, lifted to the nearest common ancestor only when a sibling needs it. - Is it UI-only but needed app-wide (auth, theme)? → a small global store. Keep it small; a global store is not a dumping ground.
Server cache rules (React Query / SWR idiom)
- Query keys are the cache's identity. Structure them hierarchically (
['users', { filters }]) so you can invalidate precisely (['users']invalidates all user queries). - Configure staleness deliberately (
staleTime) instead of refetching on every focus by reflex — but don't set it so high that users see stale data after their own edits. - Mutations invalidate, not hand-patch — after a write, invalidate the affected query keys and let the cache refetch, unless you have a specific reason to optimistically update.
- Optimistic updates need a rollback. Snapshot the previous cache, apply the optimistic change, and restore the snapshot on error. An optimistic update with no rollback path is a bug.
- Don't
useEffect(fetch)by hand. Manual fetch-in-effect leaks races (responses arriving out of order), lacks dedup/caching, and reinvents the library badly. If you must, cancel stale requests with anAbortControllerand ignore out-of-order responses.
Normalization
- When the same entity appears in multiple responses (a user in a list and in a detail view), normalize by id so one update doesn't leave a stale copy elsewhere. Cache libraries with a normalized cache (Apollo, RTK Query with
providesTags) do this; with React Query you normalize via shared query keys and targeted invalidation. - Derive, don't duplicate. If B can be computed from A, compute it during render (memoize if expensive) rather than storing B and keeping it in sync.
Global store rules (Zustand/Redux)
- Keep it small and about client state (session, theme, cross-cutting UI). Server data does not belong here — that's what the cache is for.
- Select narrowly. Subscribing a component to the whole store re-renders it on every unrelated change; select the slice you need.
- Colocate. State used by one feature lives with that feature, not in a god-store at the root.
Anti-patterns to reject
- Server response copied into
useState/global store → staleness you now own. - Filters/sort/tab in component state → lost on refresh, unshareable, back button broken. Put them in the URL.
- One giant global store holding everything, including server data.
useEffectthat syncs prop → state → prop; or fetch-in-effect without cancellation.- Deriving-by-storing: keeping a computed value in state and manually resyncing it.
- Prop-drilling deep through many layers when the data is genuinely global — that's the one case for context/global store.
Procedure
- Inventory every piece of state in the feature; classify each with the table.
- Move anything server-derived to the cache library; delete the
useStatemirrors. - Move anything shareable/refresh-surviving (filters, tabs, selection) to the URL.
- Reduce the global store to genuinely-global client state; select narrowly.
- Replace fetch-in-effect with the cache library; add rollback to any optimistic update.
- Remove derived-and-stored values; compute during render.
Definition of done
- No server data held in
useStateor the global store. - Filters/sort/tab/selection live in the URL and survive refresh + are link-shareable.
- Mutations invalidate the right query keys; optimistic updates roll back on error.
- The global store holds only small, genuinely-global client state, selected narrowly.
- No manual fetch-in-effect race; no derived-and-stored duplication.