Skip to content

Chapter 5 of 7

State Management: useReducer, Context, and External Stores

useReducer is the right primitive when the next state depends on the previous state, when the state shape is complex (nested objects, multiple sub-fields), or when updates are better expressed as named actions than as scattered setters. A reducer centralizes transitions in a pure function — easier to test, easier to extend with discriminated-union action types that give full TypeScript inference inside the case clauses. Lazy initial state (useReducer(reducer, initArg, init)) lets you defer an expensive computation. Pairing useReducer with Immer lets you write mutating drafts that produce immutable updates ergonomically. Two state-setter patterns are worth knowing everywhere: the lazy initializer useState(() => expensive()) runs the factory only on mount (avoid the trap of useState(expensive()), which runs every render), and the functional updater setCount(c => c + 1) avoids stale-closure bugs when several updates are batched before the next render.

Context is React's built-in way to share state across a subtree, but it has a sharp edge: every consumer re-renders whenever the context value changes. Mitigations include splitting contexts by domain (so an unrelated update doesn't ripple), memoizing the value passed to the provider with useMemo, and adopting a selector pattern via libraries like use-context-selector to subscribe to slices. The classic performance pattern is to split into StateContext and DispatchContext; because dispatch is referentially stable across renders, consumers that only dispatch don't need to re-render when state changes. Lifting state up is still the right call for two or three components that share state — reach for Context only when the scope is broader, and avoid it for high-frequency updates where many consumers would otherwise thrash.

For app-wide state, dedicated stores typically beat hand-rolled Context-plus-reducer. The unifying primitive is useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?), which subscribes to an external store with concurrent-rendering-safe semantics; its getSnapshot must return referentially stable values or React will warn about tearing, and the third argument is required for SSR. Zustand exposes a hook created from a store factory and supports slice selectors that re-render only when that slice changes; Jotai composes primitive atoms into derived atoms; Redux Toolkit's createSlice plus useSelector and useDispatch remains popular in larger apps, especially when paired with shallowEqual so that selecting an object doesn't force a re-render when only one of its keys changed. Compound components follow a related idea: a parent provides a Context, and children read it through a custom hook — perfect for accordions, tabs, and menus where siblings need shared state but a parent prop-drilling would be brittle.

All chapters
  1. 1Foundations: Rules, StrictMode, and the Hook Mental Model
  2. 2useEffect and Side Effects: Patterns and Pitfalls
  3. 3Refs, Stale Closures, and Imperative Escapes
  4. 4Memoization and Render Performance
  5. 5State Management: useReducer, Context, and External Stores
  6. 6Custom Hooks: Reusable Patterns
  7. 7Concurrent Features, React 19 Hooks, and Server Architecture

Drill it

Reading is not remembering. These come from the React Hooks Advanced Patterns deck:

Q

Why does useEffect run twice in dev?

Strict Mode mounts → unmounts → remounts to catch bugs from missing cleanups. Production runs effects once per mount.

Q

Common cause of infinite useEffect loop?

Updating state inside the effect with a dep that changes when state updates. Memoize the value or remove from deps if it doesn't need to trigger.

Q

How does useCallback help?

Returns the same function reference unless deps change — avoids re-rendering children that take it as a prop and use memo().

Q

useMemo vs useCallback?

useMemo(f, d): memoizes a value (the result of f()).useCallback(f, d): memoizes the function itself. Equivalent to useMemo(() => f, d).