# Frontend State Architecture

> Decide where each piece of state lives in a frontend app and stop the "everything in a global store" and "server data in useState" anti-patterns. Use when state is tangled, when adding a feature that shares data across the tree, or when choosing state tooling. Provides a decision tree separating server cache, URL, form, and client UI state, plus data-fetching and normalization rules.

- Skill: `omonuj/frontend-state-architecture` (Agent Skill)
- Install (CLI): `npx skillmds@latest add omonuj/frontend-state-architecture`
- Raw SKILL.md: https://api.skillmd.com/api/skills/omonuj/frontend-state-architecture/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: omonuj (https://skillmd.com/u/omonuj)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/omonuj/frontend-state-architecture

---


# 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
1. **Does it come from the server?** → server-cache library. Stop. Do not mirror it into `useState`.
2. **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.
3. **Is it form input being edited?** → form library (or local state for trivial forms) until submit; then it becomes a server mutation.
4. **Is it UI-only and used by one subtree?** → local `useState`, lifted to the nearest common ancestor only when a sibling needs it.
5. **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 an `AbortController` and 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.
- `useEffect` that 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
1. Inventory every piece of state in the feature; classify each with the table.
2. Move anything server-derived to the cache library; delete the `useState` mirrors.
3. Move anything shareable/refresh-surviving (filters, tabs, selection) to the URL.
4. Reduce the global store to genuinely-global client state; select narrowly.
5. Replace fetch-in-effect with the cache library; add rollback to any optimistic update.
6. Remove derived-and-stored values; compute during render.

## Definition of done
- No server data held in `useState` or 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.

