Skip to content

6.4.3 — State Management

A dashboard with a theme toggle in a context. Flip it and the whole application re-renders — including a chart that takes 90 ms and does not care about the theme.

A second team adds a user field to the same context. Now every profile update re-renders the chart too.

Neither is a bug in React or in context. It is what context does, and the first job of this page is to be precise about what each of the three common tools actually does, because most arguments about them are really arguments about which problem is being solved.

1. Most "state management" problems are a category error

Before choosing a tool, classify the state. There are five kinds and they want different homes.

KindExampleWhere it belongs
Server stateProduct list, user profileA data-fetching cache
URL stateFilters, page number, tabThe URL
Client stateTheme, sidebar open, draft basketA store or context
Form stateField values, touched, errorsThe form, locally
Ephemeral UIHover, focus, an open menuuseState in the component

The single most valuable observation in this whole area: most of what teams put in a global store is server state. A product list is not application state — it is a cache of something that lives on a server, and treating it as state means hand-writing loading flags, error flags, staleness rules, refetch-on-focus, deduplication of concurrent requests, and cache invalidation. That is a large amount of difficult code, and it is the same code in every application. Chapter 6.4.4 covers the libraries that own this problem; the point here is that moving server data out of your store often removes eighty per cent of it.

URL state is the second big one. If the user should be able to bookmark it, share it, or press Back to undo it, it belongs in the URL. Filters and pagination in a store are a bug that reports itself as "the link I sent my colleague shows different results".

And the default for everything else is useState in the component that needs it. Move state up only when a second component genuinely needs the same value, and only as far as the nearest common parent. Colocation is not a beginner strategy; it is the thing that keeps re-renders small without any tooling.

2. Context is dependency injection, not a state manager

tsx
const ThemeContext = createContext<Theme>('light');

function App() {
  const [theme, setTheme] = useState<Theme>('light');
  return (
    <ThemeContext.Provider value={theme}>
      <Dashboard />
    </ThemeContext.Provider>
  );
}

What context does: it makes a value available to any descendant without passing it through every level. That is its entire job, and it is a good one — it solves prop drilling.

What context does not do: it has no way to subscribe to part of a value. When the provider's value changes, every consumer re-renders, regardless of which field it reads. There is no selector, and adding one is not possible from the outside, because useContext does not know what you are going to do with the value.

Three consequences you will meet in that order.

The object literal that re-renders everything

tsx
// Wrong: a new object every render, so every consumer re-renders
// on every App render, even when nothing in it changed.
<AuthContext.Provider value={{ user, login, logout }}>   

// Right: identity is stable until something actually changes.
const auth = useMemo(() => ({ user, login, logout }), [user, login, logout]);
<AuthContext.Provider value={auth}>                       

Context compares with Object.is. An object literal fails that comparison every single time. This is the most common context performance bug, and it is invisible until you profile.

Split contexts by change frequency

If the value that changes often and the value that never changes live in the same context, everything re-renders at the fast rate. Separate them:

tsx
// Changes on every keystroke of a filter.
const FiltersContext = createContext<Filters | null>(null);
// Changes approximately never.
const DispatchContext = createContext<Dispatch<Action> | null>(null);

A component that only dispatches actions subscribes to DispatchContext and never re-renders when the data changes. This state-and-dispatch split is the standard pattern for useReducer shared through context, and it removes most of the re-render cost without any library.

Where context stops being enough

Context is right for values that are read widely and change rarely: theme, locale, the current user, a service or client object, feature flags.

It becomes the wrong tool when the value changes frequently and consumers care about different parts of it. At that point you need selector-based subscription, and that is exactly what a store gives you.

3. Redux, and what it was actually for

The model is three rules: one store for the whole application, state changed only by dispatching plain-object actions, and reducers that are pure functions producing a new state.

That design was not chosen for convenience. It was chosen because it makes every change describable and replayable: a serialisable list of actions plus a pure function is enough to reconstruct any state the application has ever been in. Time-travel debugging, action logs attached to bug reports, and deterministic reproduction of a user's session all fall out of it.

Modern Redux is written with Redux Toolkit, and the boilerplate criticism it earned in 2016 no longer applies:

ts
// (1) One slice: state, reducers and generated actions together.
const basketSlice = createSlice({
  name: 'basket',
  initialState: { items: [] as Item[], couponCode: null as string | null },
  reducers: {
    // (2) This LOOKS like mutation. It is not — see below.
    itemAdded(state, action: PayloadAction<Item>) {
      state.items.push(action.payload);
    },
    couponApplied(state, action: PayloadAction<string>) {
      state.couponCode = action.payload;
    },
  },
});

export const { itemAdded, couponApplied } = basketSlice.actions;   // (3)

// (4) A selector: components subscribe to the derived value, not the whole store.
export const selectItemCount = (s: RootState) => s.basket.items.length;

Line (2) is the piece that surprises people. Redux Toolkit runs reducers inside a draft proxy: your mutations are recorded and used to produce a new immutable state. You get readable code and immutable results, and you must not return a value and mutate in the same reducer, because that is ambiguous.

Line (3) generates the action creators from the reducer names, which is where most of the old boilerplate went.

Line (4) is the mechanism that avoids context's problem. useSelector(selectItemCount) re-renders only when the selected value changes, not when anything in the store changes. A selector that computes something expensive should be memoised, because it runs on every store change:

ts
// Recomputes only when items or couponCode actually change.
const selectTotal = createSelector(
  [(s: RootState) => s.basket.items, (s: RootState) => s.basket.couponCode],
  (items, coupon) => applyCoupon(items.reduce(sumPrice, 0), coupon),
);

A selector returning a new object every call defeats the whole mechanismuseSelector compares the result, so s => ({ a: s.a }) is a new object each time and re-renders always. Either memoise it or select the primitive fields separately.

When Redux is still the right answer: a large application with many teams where a uniform, inspectable pattern is worth more than brevity; anything where the action log is genuinely useful for debugging or auditing; and codebases that already have it and work. When it is not: a small application, or one whose "state" is mostly server data.

4. Zustand, and the store-as-a-hook model

ts
// (1) The store IS the hook. No provider, no context.
const useBasket = create<BasketState>()((set, get) => ({
  items: [],
  couponCode: null,

  addItem: (item) => set((s) => ({ items: [...s.items, item] })),   // (2)
  clear: () => set({ items: [], couponCode: null }),
  total: () => get().items.reduce(sumPrice, 0),                     // (3)
}));

// (4) Subscribe to exactly one value.
const count = useBasket((s) => s.items.length);

// (5) Read outside React — no hook needed.
useBasket.getState().addItem(product);

Line (1) is the structural difference: there is no provider in the tree. The store is a module-level object, and the hook subscribes to it. That is less ceremony, and it has one consequence covered in section 5 that you must not miss.

Line (2) sets state by merging at the top level — a shallow merge, unlike React's useState which replaces.

Line (4) is a selector, working the same way Redux's does: this component re-renders only when items.length changes. Selecting an object needs a shallow comparator (useShallow), for exactly the reason a Redux selector does.

Line (5) is genuinely useful and easy to abuse: any module can read or write the store without being a component. Good for an event handler outside React or a WebSocket callback; bad as a way to avoid thinking about where state belongs.

Underneath, Zustand and Redux both use useSyncExternalStore (Chapter 6.4.2), which is what makes them safe under concurrent rendering.

The comparison that matters

ContextRedux ToolkitZustand
Re-render granularityAll consumersPer selectorPer selector
Provider neededYesYesNo
BoilerplateLeastModerateLittle
Time-travel toolingNoYesPartial
Best forRarely changing valuesLarge, multi-teamSmall to medium client state

The honest summary: context for injection, a selector-based store for frequently changing shared state, and a data-fetching cache for anything that came from a server. Most applications need all three, and choosing "one state management solution" is usually the mistake.

5. Collisions, and the module-level store on a server

Two problems that are specific to stores and cause real incidents.

Name collisions between features

A single global store with a flat shape invites two teams to define status, or two features to persist under the key settings. The fixes are ordinary and worth stating: namespace by feature (basket.items, checkout.status), keep each slice's reducers in that slice, and never let a feature reach into another feature's shape — expose a selector instead. A selector is a contract; reaching into state.other.thing.deep is a dependency nobody declared.

For persisted state the collision is worse, because the key lives in localStorage alongside every other script on the origin. Prefix every key with your application name and a version, and treat the whole persisted blob as one versioned document (below).

The one that is a security bug

A module-level store is created once per module instance. In the browser that is once per page — exactly what you want. On a server rendering pages for many users, it is once per process, shared by every request.

ts
// In a server-rendered application, this is one object for ALL users.
export const useUser = create<UserState>()(() => ({ user: null }));   

Request A sets the user; request B, arriving a moment later, renders with request A's user and can leak their name, their basket, or their permissions into someone else's page. This is not a theoretical concern — it is a well-known class of bug in server-rendered React applications and it has caused real disclosures.

The rule: on the server, a store must be created per request, and passed down through a provider rather than imported as a singleton. Redux has always required a provider, which makes the correct thing the default. With a store-as-hook library you must create the store inside a provider component for server-rendered applications, and the convenience of getState() from anywhere is exactly what you are giving up. Chapter 6.5.1 covers the server-rendering context this sits in.

6. Persistence, and doing it without breaking the first render

ts
const useSettings = create<Settings>()(
  persist(
    (set) => ({ theme: 'light', density: 'comfortable', lastViewed: null }),
    {
      name: 'shop:settings',                                   // (1) namespaced key
      version: 3,                                              // (2)
      migrate: (persisted, from) => {                          // (3)
        if (from < 3) return { ...(persisted as object), density: 'comfortable' };
        return persisted as Settings;
      },
      partialize: (s) => ({ theme: s.theme, density: s.density }),  // (4)
    },
  ),
);

Line (1) is the collision fix. Line (2) and line (3) are the part people skip and then regret: persisted state outlives your code. A user's browser holds a shape you shipped six months ago, and the new version's code will read it. Without a version and a migration, the application either crashes on a missing field or, worse, runs with a half-old object and misbehaves quietly. A version number and a migration function are five lines that prevent a support queue.

Line (4) partialize chooses what to save. Do not persist everything. Never persist server data (it goes stale and you already have a cache), never persist tokens (Chapter 6.3.3), and never persist transient interface state like "modal open" — a user who returns to a modal they never opened will assume the site is broken.

Remember also that localStorage writes are synchronous and on the main thread (Chapter 6.3.3), so persisting a large object on every keystroke is a real freeze. Persist on a debounce, or persist only on meaningful transitions.

Hydration: the mismatch, and the two correct fixes

Server-side rendering produces HTML on the server, and the browser then attaches React to it. React requires the first client render to produce exactly the markup the server sent (Chapter 6.5.1 covers why).

Persisted state breaks this by construction:

  • The server has no access to localStorage. It renders with the default theme: 'light'.
  • The client reads localStorage and finds theme: 'dark'.
  • The first client render disagrees with the server HTML. React logs a hydration error and, in the worst case, throws the server markup away and re-renders the whole page on the client — losing the entire benefit of server rendering.

Fix one: render the default, then apply the stored value in an effect.

tsx
function ThemeRoot({ children }: { children: ReactNode }) {
  const [hydrated, setHydrated] = useState(false);          // (1)
  useEffect(() => setHydrated(true), []);                   // (2)

  const theme = useSettings((s) => s.theme);
  // (3) Until hydration completes, render exactly what the server rendered.
  return <div data-theme={hydrated ? theme : 'light'}>{children}</div>;
}

Lines (1)–(3) make the first client render identical to the server's by construction, and the real value appears on the second render. The cost is one frame of the default appearance — for a theme, that is a visible flash, which is why the second fix exists for that specific case.

Fix two, for theme specifically: decide before React exists. A tiny blocking inline script in <head> reads storage and sets an attribute on <html> before the first paint:

html
<script>
  try {
    const t = localStorage.getItem('shop:theme');
    document.documentElement.dataset.theme =
      t || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
  } catch {}
</script>

It is one of the very few places a deliberately render-blocking inline script is correct (Chapter 6.1.2): it must run before paint, or the user sees a white flash. Styling is then driven by the attribute in CSS, so React never has to know the theme at all during hydration.

Do not reach for suppressHydrationWarning to make the error go away. It silences the message without fixing the mismatch, and the mismatch is the thing causing the re-render.

What the interviewer will push on

"When do you need a state management library?" Start by reclassifying: most of what lands in a store is server state or URL state and belongs elsewhere. Then: context for rarely changing injected values, a selector store for frequently changing shared client state. A candidate who names the categories before naming a library is answering the real question.

"Why is context not a state manager?" It has no partial subscription — every consumer re-renders when the value changes. Then name the two practical fixes: memoise the provider value, and split contexts by change frequency.

"What is the most common context performance bug?" An object literal as value, so identity changes every render. It is invisible without a profiler.

"Redux versus Zustand?" Both give selector-level subscription. Redux brings a uniform pattern, a serialisable action log and time travel, and requires a provider — which is also why it is safe by default on a server. Zustand is a module-level store with no provider, less ceremony, and that same module-level nature is the thing you must handle carefully when server rendering.

"What goes wrong with a module-level store during server rendering?" It is shared across requests in one process, so one user's data can render into another user's page. Create the store per request and pass it through a provider. This is the answer that separates people who have run React on a server from people who have not.

"How do you persist state without breaking hydration?" The server cannot see localStorage, so the first client render must match the server. Either render the default and apply the stored value in an effect, or — for theme — set an attribute from a blocking inline script before paint. Never suppressHydrationWarning.

"What do you version and migrate?" Anything persisted. Users' browsers hold shapes you shipped months ago, so a version number plus a migration function is mandatory, and partialize keeps server data, tokens and transient interface state out of storage entirely.

One thing to volunteer: point out that filters and pagination belong in the URL, not in a store, and that the bug report which proves it is "the link I shared shows different results". It reframes state management as a product decision rather than a library choice, which is the level the question is really aimed at.

Recall

  • Classify first: server state (a cache, not state), URL state (bookmarkable, shareable, Back-able), client state, form state, ephemeral UI. Most store bloat is server state in the wrong place.
  • Default to useState in the component; lift only to the nearest common parent that needs it.
  • Context is dependency injection, not a store. No partial subscription — every consumer re-renders when value changes. Memoise the value (an object literal re-renders everything) and split contexts by change frequency, typically state and dispatch.
  • Redux = one store, plain actions, pure reducers, chosen so every change is serialisable and replayable. Redux Toolkit removed the boilerplate; its reducers look mutable because they run inside a draft proxy. useSelector subscribes to the selected value — a selector returning a new object defeats it.
  • Zustand = a module-level store that is itself a hook, no provider, selector subscriptions, and getState() outside React. Both libraries sit on useSyncExternalStore.
  • Collisions: namespace slices by feature, expose selectors instead of letting features reach into each other's shape, and prefix persisted keys with app and version.
  • A module-level store on a server is shared between requests — one user's data can render into another's page. Create the store per request behind a provider.
  • Persistence needs a version and a migrate, because users' browsers hold shapes you shipped months ago. partialize out server data, tokens and transient UI. Writes are synchronous — debounce them.
  • Hydration: the server cannot read localStorage, so the first client render must match the server HTML. Render the default and apply stored state in an effect, or set a data-theme attribute from a blocking inline script before paint. Never paper over it with suppressHydrationWarning.

Self-test: Which of your "global state" is actually a server cache? · Why does a theme change re-render an unrelated chart? · What exactly does a selector buy over context? · What is the incident caused by a module-level store during server rendering? · Why does persisted state break hydration, and what are the two correct fixes?

Next: 6.4.4 covers the parts of React that are conventions rather than mechanisms — how events are actually attached, the composition patterns that survived, portals, error boundaries, and the data-fetching layer that removes most of the code this page just told you not to write.