ESC

Type to search the knowledge base.

State Management at Scale

System design for frontend state at scale — server vs client state, boundaries, stores, and consistency patterns.

intermediate4 min read
  • 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

  1. Optimistic UI with rollback
  2. Single-flight mutations
  3. Version/etag conflict → prompt refresh
  4. 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

  1. Normalized global entities vs colocated feature caches
  2. Optimistic vs pessimistic mutations
  3. URL as state vs hidden store (shareability)
  4. 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:

  1. Moving fetches to React Query one domain at a time
  2. Leaving true client workflows in RTK/Zustand
  3. 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.

Further reading