Skip to content

State Management at Scale

advanced22 min read

The State Taxonomy Nobody Teaches

Most frontend codebases shove everything into a single global store. User profile, API responses, form inputs, UI toggles, URL parameters — all living in one Redux store with 200 reducers. This is the architectural equivalent of putting every file on your desktop.

State has categories. Each category has fundamentally different characteristics — staleness, ownership, scope, and persistence. Treating them the same is why your state management "doesn't scale."

Mental Model

Think of state like mail in a building. Server state is mail from outside — it arrives, it might be stale, and someone else controls the source of truth. Client state is notes you write to yourself — you own it entirely. URL state is the address on the building — it's public, shareable, and bookmarkable. Form state is a draft letter — temporary, local, and discarded when you're done. You wouldn't store all of these in the same mailbox. Each has a different lifecycle, different ownership, and different storage requirements.

The Four Categories of State

1. Server State

Data that originates from a remote source. The server is the source of truth, and your client copy is a cache that can become stale at any moment.

// This is server state — it came from an API
const user = { id: '123', name: 'Alice', plan: 'pro' };
const products = [{ id: 'p1', name: 'Widget', price: 29.99 }];

Server state has unique concerns that client state doesn't:

  • Caching: How long is the data fresh?
  • Deduplication: Two components requesting the same data shouldn't trigger two fetches
  • Background refetching: Data should refresh when the user returns to the tab
  • Optimistic updates: Show the expected result before the server confirms
  • Retry and error recovery: Network failures need automatic retry with backoff

Putting server state in Redux means you must solve all of these yourself. Libraries like TanStack Query and SWR solve them out of the box.

// TanStack Query handles caching, deduplication, refetching, retries
const { data: user, isLoading, error } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
  staleTime: 5 * 60 * 1000, // fresh for 5 minutes
});
Quiz
Two components on the same page both call useQuery({ queryKey: ['user', '123'] }). What happens?

2. Client State

State that exists only in the browser. The server doesn't know about it and doesn't care. This is the only category where traditional state management (Context, Zustand, Jotai) is the right tool.

// True client state — no server equivalent
const [isSidebarOpen, setSidebarOpen] = useState(false);
const [selectedTheme, setSelectedTheme] = useState('dark');
const [activeTab, setActiveTab] = useState('overview');

Client state is typically small. If your global store has 50 slices and 45 of them are API responses, you don't have a client state problem — you have a server state problem being solved with the wrong tool.

3. URL State

State encoded in the URL — query parameters, path segments, hash fragments. URL state is special because it's shareable, bookmarkable, and survives page refreshes.

// /products?category=electronics&sort=price&page=3
// This IS the state. Don't duplicate it in a store.
const searchParams = useSearchParams();
const category = searchParams.get('category');
const sort = searchParams.get('sort');
const page = Number(searchParams.get('page')) ?? 1;
Common Trap

A common anti-pattern: reading URL params into a Redux store on mount, then using the store everywhere. Now you have two sources of truth — the URL and the store. They will drift. The URL IS the state. Read from it directly. When you need to change it, update the URL and let components react to that change.

4. Form State

Ephemeral state tied to a user's in-progress input. It has a clear lifecycle: initialize → edit → submit → discard. Form state rarely needs to be global.

// React 19 form state with useActionState
const [state, formAction, isPending] = useActionState(submitForm, initialState);

// Or react-hook-form for complex forms
const { register, handleSubmit, formState } = useForm<CheckoutForm>();
Quiz
A product listing page shows items from an API, with a search filter in the URL and a sidebar toggle for mobile. Where should each piece of state live?

Why Global State Is Overused

Here's the thing: Redux became the default because it appeared before better alternatives existed. In 2016, there was no TanStack Query for server state, no useSearchParams for URL state, no useActionState for form state. Redux was the only game in town, so everything went there.

The cost of over-centralization:

  1. Boilerplate explosion — Actions, reducers, selectors, thunks for every API endpoint
  2. Unnecessary re-renders — One slice update triggers selector recalculations across unrelated components
  3. Stale data bugs — No automatic cache invalidation means you build it yourself (and get it wrong)
  4. Testing complexity — Every test needs a store setup with mock state for unrelated slices

Client State: Choosing the Right Tool

Once you've moved server state out of your store, you'll find the remaining client state is surprisingly small. Pick your tool based on update frequency:

ToolBest ForRe-render Behavior
React ContextLow-frequency state (theme, auth, locale)All consumers re-render on any change
ZustandMedium-to-high frequency, multiple slicesSelector-based — only subscribing components re-render
JotaiFine-grained atomic stateAtom-level subscriptions — minimal re-renders
URL (searchParams)Shareable, bookmarkable stateComponents re-render on navigation
// Zustand — minimal API, selector subscriptions
import { create } from 'zustand';

interface UIStore {
  sidebarOpen: boolean;
  toggleSidebar: () => void;
  activeModal: string | null;
  openModal: (id: string) => void;
  closeModal: () => void;
}

const useUIStore = create<UIStore>((set) => ({
  sidebarOpen: false,
  toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
  activeModal: null,
  openModal: (id) => set({ activeModal: id }),
  closeModal: () => set({ activeModal: null }),
}));

// Component subscribes to ONLY sidebarOpen — doesn't re-render when activeModal changes
const isSidebarOpen = useUIStore((s) => s.sidebarOpen);
Quiz
You use React Context for a global theme (light/dark). Changing the theme causes 200+ components to re-render, most of which don't use the theme. Why?

State Machines with XState

Ever had a bug where your UI shows a loading spinner and an error message at the same time? State machines eliminate that entire category of bug. For complex state with well-defined transitions — multi-step wizards, authentication flows, payment processing — they make impossible states truly impossible.

import { createMachine, assign } from 'xstate';

const checkoutMachine = createMachine({
  id: 'checkout',
  initial: 'cart',
  context: { items: [], error: null },
  states: {
    cart: {
      on: { PROCEED: 'shipping' }
    },
    shipping: {
      on: {
        BACK: 'cart',
        SUBMIT_ADDRESS: 'payment'
      }
    },
    payment: {
      on: {
        BACK: 'shipping',
        SUBMIT_PAYMENT: 'processing'
      }
    },
    processing: {
      invoke: {
        src: 'processPayment',
        onDone: 'confirmation',
        onError: {
          target: 'payment',
          actions: assign({ error: (_, event) => event.data.message })
        }
      }
    },
    confirmation: { type: 'final' }
  }
});

The machine makes illegal transitions impossible at the type level. You cannot go from cart directly to confirmation. You cannot submit payment from the shipping state. Every valid path is explicit.

When state machines are overkill

State machines add upfront design cost. They pay off when your state has 4+ states with complex transitions, when bugs come from impossible state combinations (loading AND error simultaneously), or when the flow has strict ordering requirements. For a simple boolean toggle or a list filter, useState or Zustand is simpler and perfectly adequate. Don't reach for XState for an isOpen flag.

Normalized State Shape

When client state includes relational data (lists of items that reference each other), normalize it like a database:

// Denormalized — duplicated data, update anomalies
const state = {
  posts: [
    { id: '1', title: 'Hello', author: { id: 'u1', name: 'Alice' } },
    { id: '2', title: 'World', author: { id: 'u1', name: 'Alice' } },
    // If Alice changes her name, you must update every post
  ]
};

// Normalized — single source of truth per entity
const state = {
  posts: {
    byId: { '1': { id: '1', title: 'Hello', authorId: 'u1' },
            '2': { id: '2', title: 'World', authorId: 'u1' } },
    allIds: ['1', '2']
  },
  users: {
    byId: { 'u1': { id: 'u1', name: 'Alice' } },
    allIds: ['u1']
  }
};

Normalization eliminates data duplication, makes updates O(1) by ID, and prevents the classic bug where the same entity has different values in different parts of the state tree.

Quiz
You have 1000 comments, each with an embedded author object. The user updates their display name. In denormalized state, how many objects must you update?

Selector Patterns for Derived State

This is a rule worth tattooing on your forearm: never store derived state. Compute it from base state using selectors:

// Bad — storing derived state
const state = {
  items: [...],
  totalPrice: 142.50,     // derived from items
  itemCount: 5,           // derived from items
  hasExpensiveItems: true, // derived from items
};

// Good — compute with selectors
const selectItems = (state) => state.items;
const selectTotalPrice = (state) =>
  state.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const selectItemCount = (state) =>
  state.items.reduce((sum, item) => sum + item.quantity, 0);
const selectHasExpensiveItems = (state) =>
  state.items.some(item => item.price > 100);

For expensive derivations, memoize the selector so it only recalculates when inputs change:

import { createSelector } from 'reselect';

const selectExpensiveReport = createSelector(
  [selectItems, selectFilters],
  (items, filters) => {
    // Only runs when items or filters change
    return items
      .filter(matchesFilters(filters))
      .sort(byCriteria(filters.sortBy))
      .map(enrichWithMetrics);
  }
);

The Colocation Principle

State should live as close to where it's used as possible. Start local, lift only when needed:

  1. Component state (useState) — default. Use unless you have a reason not to
  2. Shared between siblings — lift to parent
  3. Shared across distant components — Context (low frequency) or Zustand/Jotai (high frequency)
  4. Shared across routes — URL state or global store
  5. Persisted across sessions — localStorage + hydration or server
Quiz
A dropdown's open/closed state is used by the dropdown component and its trigger button (siblings). Where should this state live?
Key Rules
  1. 1Classify state into four categories: server state (TanStack Query/SWR), client state (useState/Zustand/Jotai), URL state (searchParams), and form state (useActionState/react-hook-form).
  2. 2Server state is a cache of remote data. Use TanStack Query or SWR — never Redux — for caching, deduplication, background refetching, and optimistic updates.
  3. 3Global client state should be small. If your store has 50 slices and 45 are API data, you're using the wrong tool for server state.
  4. 4React Context re-renders all consumers on any change. Use it for low-frequency state (theme, auth). Use Zustand or Jotai with selectors for high-frequency updates.
  5. 5Normalize relational state like a database — byId maps eliminate duplication and make updates O(1).
  6. 6Never store derived state. Compute it with selectors, memoize expensive derivations with createSelector.
  7. 7Colocate state: start with useState, lift only when sharing is needed. Most state is more local than you think.
Interview Question

Q: You're joining a team whose Redux store has 80 slices — 60 of which are API response caches, 10 are form states, and 10 are true UI state. How would you refactor this?

A strong answer: Migrate the 60 API slices to TanStack Query (get caching, deduplication, background refetch, and optimistic updates for free). Replace the 10 form slices with react-hook-form or useActionState (form state is ephemeral, doesn't belong in a global store). Keep only the 10 true UI slices, and evaluate whether they can be simplified with Zustand or even colocated as local state. The Redux store shrinks from 80 slices to 0-10, with better behavior in every category.