State Management at Scale
System design for frontend state at scale — server vs client state, boundaries, stores, and consistency patterns.
- system-design
- interview
- architecture
- state
Scope the problem
At scale the problem isn’t “Redux vs MobX” — it’s which state exists, who owns it, and how it invalidates.
Taxonomy of state
| Kind | Examples | Default home |
|---|---|---|
| Server/cache | products, user profile, notifications | React Query / SWR / RTK Query |
| URL | filters, tab, page, selected id | router |
| Session UI | modal open, wizard step local | component / short context |
| Global client | theme, locale, auth status | small store / context |
| Ephemeral realtime | cursors, presence | dedicated store |
| Form draft | long forms | local + maybe persist |
Rule: don’t put server lists in a giant global Redux blob without a caching strategy.
Architecture
┌─────────────────────────────────────────────┐
│ URL state (shareable, back/forward) │
├─────────────────────────────────────────────┤
│ Server state library (cache, dedupe, SWR) │
├─────────────────────────────────────────────┤
│ Feature stores (checkout cart, editor doc) │
├─────────────────────────────────────────────┤
│ Local component state │
└─────────────────────────────────────────────┘
Server state patterns
useQuery({
queryKey: ["project", id],
queryFn: () => api.project(id),
staleTime: 30_000,
});
useMutation({
mutationFn: updateProject,
onMutate: optimisticUpdate,
onError: rollback,
onSettled: () => qc.invalidateQueries({ queryKey: ["project", id] }),
});
| Concern | Approach |
|---|---|
| Deduping | query keys |
| Consistency | invalidate/refetch; selective setQueryData |
| Pagination | infinite queries + cursors |
| Cross-feature | shared keys documented |
Client global state
Use a store when many distant components write/read non-server data (cart, feature tour). Prefer:
- Context for low-frequency (theme)
- Zustand/Jotai/Redux for high-frequency selective subscriptions
Avoid Context for values changing every keystroke (re-render storms).
Selector discipline
const qty = useCart((s) => s.items.length); // not whole state
Normalization
Entities by id prevent duplicated inconsistent objects:
type Entities = {
users: Record<string, User>;
posts: Record<string, Post>;
};
// post.authorId → users
Server state libs + normalized caches (or graph clients) help at scale.
Feature boundaries
features/checkout/model/* owns cart transitions
features/catalog/* does not import checkout internals
app shell wires providers only
Shared kernel: auth session, analytics, design system — not business stores.
Concurrency & consistency
- Optimistic UI with rollback
- Single-flight mutations
- Version/etag conflict → prompt refresh
- Multi-tab: BroadcastChannel for auth logout / cart merge strategy
Performance
- Colocate state to minimize subscriptions
- Virtualize big lists derived from store
- Don’t derive heavy arrays inline without memo
- Code-split feature stores with routes
Anti-patterns
- One RootState with entire backend mirrored
- Fetch in every component without cache
- Duplicating URL state into Redux out of sync
- Prop drilling 8 levels instead of composition or store
Choosing tools (pragmatic)
| Need | Tooling |
|---|---|
| Async server cache | React Query / SWR |
| Complex client workflows | Redux Toolkit / XState |
| Simple global bits | Zustand |
| Form heavy | React Hook Form + local |
| Realtime presence | separate store |
Tradeoffs
- Normalized global entities vs colocated feature caches
- Optimistic vs pessimistic mutations
- URL as state vs hidden store (shareability)
- Strong typing of store vs speed of iteration
Interview close
Classify state → server state in a cache library → URL for shareable UI → thin global store for true cross-cutting client state → normalize entities when overlap hurts → multi-tab and optimistic strategies. Tool choice last, boundaries first.
Example ownership table
| State | Owner | Storage |
|---|---|---|
| Session user | AuthProvider | memory + httpOnly cookie session |
| Product detail | React Query | memory cache |
| SRP filters | URL | history |
| Cart | CartStore | memory + local persistence |
| Modal stack | local UI | component state |
| Presence | RealtimeStore | memory |
Migration story
Legacy “everything in Redux” apps migrate by:
- Moving fetches to React Query one domain at a time
- Leaving true client workflows in RTK/Zustand
- Deleting duplicated entity slices when query cache is source of truth
Testing
Prefer testing feature behavior with mock server handlers over deep store inspection. Snapshotting entire root state couples tests to implementation.
Related on this site
- Client-side Routing at Scale
- Auth Session Design for SPAs
- Kanban Board
- System Design Interview Framework