DNAπŸ—οΈ System DesignState Management Architecture
πŸ‘‘ApexSystem DesignState ManagementArchitecture

State Management Architecture

State is the source of most frontend bugs. Understanding where state lives, how it flows, and when to reach for global solutions is what turns a React developer into a frontend architect.

State Management Architecture

Every bug you've spent more than 30 minutes on was probably a state bug. Stale closures, race conditions, impossible UI states, data that's out of sync β€” all of these are state management failures. Architects don't just pick a library; they design a state topology.

The State Topology Spectrum

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Local   │──▢│  Lifted  │──▢│   Global   │──▢│ Server Cache β”‚
β”‚  State   β”‚   β”‚  State   β”‚   β”‚   State    β”‚   β”‚              β”‚
β”‚          β”‚   β”‚          β”‚   β”‚            β”‚   β”‚              β”‚
β”‚ useState β”‚   β”‚ parent   β”‚   β”‚ Zustand    β”‚   β”‚ React Query  β”‚
β”‚ useRef   β”‚   β”‚ passes   β”‚   β”‚ Redux TK   β”‚   β”‚ SWR          β”‚
β”‚          β”‚   β”‚ down     β”‚   β”‚ Jotai      β”‚   β”‚ Apollo       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
     β–²                                               β–²
     β”‚              START HERE                       β”‚
     β”‚         (move right only when needed)          β”‚
     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The golden rule: state should live as close to where it's consumed as possible. Every layer you move rightward adds complexity, indirection, and potential for bugs.

The Five Layers of Frontend State

Not all state is the same. Treating it as one blob is the root cause of most state management pain.

Layer 1: UI State

Ephemeral, component-scoped state that affects presentation only.

const [isOpen, setIsOpen] = useState(false);        // dropdown open
const [activeTab, setActiveTab] = useState('info');  // tab selection
const [isHovered, setIsHovered] = useState(false);   // hover effect

Where it lives: useState, useReducer β€” local to the component. Never globalize this.

Layer 2: Form State

Input values, validation, dirty tracking, submission status.

const form = useForm({
  defaultValues: { name: '', email: '' },
  resolver: zodResolver(schema),
});

Where it lives: Form library (React Hook Form, Formik) or local useReducer. Form state is inherently local to the form β€” globalizing it causes needless re-renders across the app.

Layer 3: URL State

Current route, search params, filters, pagination cursors.

const [searchParams, setSearchParams] = useSearchParams();
const page = Number(searchParams.get('page')) ?? 1;
const sort = searchParams.get('sort') ?? 'newest';

Where it lives: The URL. This is the most underused state container. URL state is shareable (copy-paste a link), bookmarkable, and survives refreshes for free.

Layer 4: Server Cache

Data fetched from APIs. This is NOT application state β€” it's a cache of someone else's state (the server's).

const { data: posts, isLoading } = useQuery({
  queryKey: ['posts', { page, sort }],
  queryFn: () => fetchPosts({ page, sort }),
  staleTime: 30_000,
});

Where it lives: React Query, SWR, Apollo Client. These tools handle caching, deduplication, background refetching, and cache invalidation β€” things you'd get wrong building from scratch.

Layer 5: Global Application State

Truly shared state that multiple distant components need: auth user, theme, feature flags, notification count.

const useAuthStore = create<AuthState>((set) => ({
  user: null,
  token: null,
  login: async (credentials) => {
    const { user, token } = await authApi.login(credentials);
    set({ user, token });
  },
  logout: () => set({ user: null, token: null }),
}));

Where it lives: Zustand, Redux Toolkit, Jotai, or React Context (for low-frequency updates).

The Layer Map

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    State Layers                         β”‚
β”‚                                                        β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  Changes every     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚  UI State   β”‚  interaction  ────▢│ useState       β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  Changes per       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚ Form State  β”‚  keystroke   ────▢│ React Hook Formβ”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  Changes per       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚ URL State   β”‚  navigation  ────▢│ URL / Router   β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  Changes on        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚Server Cache β”‚  fetch/mutate ───▢│ React Query    β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  Changes           β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚ Global App  β”‚  infrequently ───▢│ Zustand/Redux  β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Client State vs Server State

This distinction is the most important mental model shift in modern frontend architecture.

CharacteristicClient StateServer State
OwnerThe browserThe server/database
PersistenceEphemeralPersisted
SynchronizationNot neededMust sync with source of truth
StalenessDoesn't go staleGoes stale immediately
SharedSingle userMultiple users
ExampleSidebar open, modal visibleUser profile, post list

The mistake: putting server data into Redux. You end up manually handling loading states, errors, caching, deduplication, background refresh, and cache invalidation. React Query handles all of this.

❌  Redux with server data:
    dispatch(fetchPostsRequest())
    dispatch(fetchPostsSuccess(data))
    dispatch(fetchPostsFailure(error))
    + manual cache invalidation
    + manual deduplication
    + manual background refresh
 
βœ…  React Query:
    useQuery({ queryKey: ['posts'], queryFn: fetchPosts })
    (loading, error, caching, dedup, refresh β€” all handled)

State Machines for Complex Flows

When state has well-defined transitions and impossible states should be impossible, use a state machine.

Example: Payment Flow

                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                β”‚   idle   β”‚
                β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
                     β”‚ SUBMIT
                β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”
          β”Œβ”€β”€β”€β”€β”€β”‚validating│─────┐
          β”‚     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β”‚
       VALID                  INVALID
          β”‚                      β”‚
     β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”          β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”
     β”‚processingβ”‚          β”‚  error   │──── RETRY ──▢ idle
     β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
          β”‚
    β”Œβ”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”
 SUCCESS      FAILURE
    β”‚            β”‚
β”Œβ”€β”€β”€β–Όβ”€β”€β”€β”  β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”
β”‚success β”‚  β”‚  failed  │──── RETRY ──▢ idle
β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
import { createMachine, assign } from 'xstate';
 
const paymentMachine = createMachine({
  id: 'payment',
  initial: 'idle',
  context: { error: null, retries: 0 },
  states: {
    idle: {
      on: { SUBMIT: 'validating' },
    },
    validating: {
      invoke: {
        src: 'validatePayment',
        onDone: 'processing',
        onError: { target: 'error', actions: assign({ error: (_, e) => e.data }) },
      },
    },
    processing: {
      invoke: {
        src: 'processPayment',
        onDone: 'success',
        onError: { target: 'failed', actions: assign({ error: (_, e) => e.data }) },
      },
    },
    success: { type: 'final' },
    failed: {
      on: {
        RETRY: {
          target: 'idle',
          guard: ({ context }) => context.retries < 3,
          actions: assign({ retries: ({ context }) => context.retries + 1 }),
        },
      },
    },
    error: {
      on: { RETRY: 'idle' },
    },
  },
});

State machines make impossible states unrepresentable. You can't be in processing and error simultaneously. You can't transition from success to validating. The machine enforces these rules at the type level.

Optimistic Updates

Show the result of an action immediately, before the server confirms it.

const likeMutation = useMutation({
  mutationFn: (postId: string) => api.likePost(postId),
  onMutate: async (postId) => {
    await queryClient.cancelQueries({ queryKey: ['posts'] });
    const previous = queryClient.getQueryData(['posts']);
 
    queryClient.setQueryData(['posts'], (old: Post[]) =>
      old.map(post =>
        post.id === postId
          ? { ...post, liked: true, likeCount: post.likeCount + 1 }
          : post
      )
    );
 
    return { previous };
  },
  onError: (_err, _postId, context) => {
    queryClient.setQueryData(['posts'], context?.previous);
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['posts'] });
  },
});

The pattern: Snapshot β†’ Optimistic update β†’ On error rollback β†’ On settle revalidate.

Cache Invalidation Strategies

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚              Cache Invalidation                      β”‚
β”‚                                                     β”‚
β”‚  Time-based    β”‚ staleTime: 30s, refetch every 60s  β”‚
β”‚  Event-based   β”‚ Invalidate on mutation success     β”‚
β”‚  Tag-based     β”‚ Invalidate all queries with tag    β”‚
β”‚  Manual        β”‚ queryClient.invalidateQueries()    β”‚
β”‚  Pessimistic   β”‚ Wait for server, then update cache β”‚
β”‚  Optimistic    β”‚ Update cache immediately            β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

React Query's Invalidation Model

queryClient.invalidateQueries({ queryKey: ['posts'] });
 
queryClient.invalidateQueries({
  queryKey: ['posts'],
  exact: false,
});
 
queryClient.invalidateQueries({
  predicate: (query) => query.queryKey[0] === 'user' && query.queryKey[1] === userId,
});

State Synchronization

Cross-Tab Synchronization

When a user logs out in one tab, all tabs should reflect it:

const channel = new BroadcastChannel('auth-sync');
 
channel.onmessage = (event) => {
  if (event.data.type === 'LOGOUT') {
    authStore.getState().logout();
    router.push('/login');
  }
};
 
function logout() {
  authStore.getState().logout();
  channel.postMessage({ type: 'LOGOUT' });
}

Offline Synchronization

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Online  │───────▢│ Offline  │───────▢│  Sync    β”‚
β”‚          β”‚ lost   β”‚          β”‚ regain β”‚          β”‚
β”‚ Read/    β”‚ conn   β”‚ Read fromβ”‚ conn   β”‚ Push     β”‚
β”‚ Write to β”‚        β”‚ local DB β”‚        β”‚ queued   β”‚
β”‚ server   β”‚        β”‚ Queue    β”‚        β”‚ writes   β”‚
β”‚          β”‚        β”‚ writes   β”‚        β”‚ Resolve  β”‚
β”‚          β”‚        β”‚          β”‚        β”‚ conflictsβ”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Normalized vs Denormalized State

Denormalized (duplicated data)

{
  "posts": [
    { "id": "p1", "author": { "id": "u1", "name": "Alice", "avatar": "..." } },
    { "id": "p2", "author": { "id": "u1", "name": "Alice", "avatar": "..." } }
  ]
}

Problem: Update Alice's avatar β†’ find and update every post by Alice.

Normalized (single source of truth)

{
  "entities": {
    "users": { "u1": { "id": "u1", "name": "Alice", "avatar": "..." } },
    "posts": {
      "p1": { "id": "p1", "authorId": "u1" },
      "p2": { "id": "p2", "authorId": "u1" }
    }
  },
  "feedOrder": ["p1", "p2"]
}

Update Alice's avatar β†’ one update to entities.users.u1. All posts reference it by ID.

When to normalize: large datasets with shared entities (social apps, dashboards). When to skip: small datasets, server-driven UIs, pages without cross-entity updates.

When NOT to Use Global State

Global state is overused. You don't need it when:

  • Only one component reads it β†’ useState
  • Only parent-child share it β†’ props
  • It's server data β†’ React Query
  • It's URL-representable β†’ useSearchParams
  • It's form data β†’ form library
  • It changes on every render β†’ useRef

State Management Selection Matrix

CriteriaContextZustandRedux TKJotaiReact Query
Learning curveLowLowMediumLowMedium
BoilerplateLowVery lowMediumVery lowLow
DevToolsNoneBasicExcellentBasicExcellent
Re-render controlPoor (all consumers)Good (selectors)Good (selectors)Excellent (atomic)N/A
Server stateDon'tDon'tDon't (use RTK Query)Don'tYes
Bundle size0 KB~1 KB~11 KB~2 KB~13 KB
MiddlewareNoneSimpleRich ecosystemNoneBuilt-in
Best forTheme, localeMost client stateComplex client stateFine-grained reactivityServer cache

Decision Flowchart

Is it server data?
  β”œβ”€ YES β†’ React Query / SWR / RTK Query
  └─ NO β†’ Is it URL-representable?
       β”œβ”€ YES β†’ URL search params
       └─ NO β†’ Is it form data?
            β”œβ”€ YES β†’ React Hook Form / local reducer
            └─ NO β†’ How many components need it?
                 β”œβ”€ 1 β†’ useState
                 β”œβ”€ Parent + children β†’ props / composition
                 └─ Distant components β†’ Zustand (default)
                      └─ Need middleware/devtools/ecosystem? β†’ Redux TK
                      └─ Need atomic fine-grained updates? β†’ Jotai

Architecture Example: E-Commerce Cart

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚              State Architecture              β”‚
β”‚                                             β”‚
β”‚  URL State                                   β”‚
β”‚  └─ /products?category=shoes&sort=price     β”‚
β”‚                                             β”‚
β”‚  Server Cache (React Query)                  β”‚
β”‚  └─ products list, product details, reviews β”‚
β”‚                                             β”‚
β”‚  Global State (Zustand)                      β”‚
β”‚  └─ cart items, auth user, theme            β”‚
β”‚                                             β”‚
β”‚  Form State (React Hook Form)               β”‚
β”‚  └─ checkout form, search input             β”‚
β”‚                                             β”‚
β”‚  UI State (useState)                         β”‚
β”‚  └─ mobile menu open, image zoom, tooltips  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Each layer uses the right tool. No single library tries to do everything. This is what a well-architected state topology looks like β€” and it's exactly what interviewers want to hear you articulate.