intermediate
frontend

State Management Patterns

Context API, useReducer, and when to reach for external state libraries

react fundamentals
28 min read

State Management in React

Every non-trivial React app eventually faces the same question: where should this piece of state live, and how do components far apart in the tree read and update it? Get it wrong and you end up prop-drilling values through ten layers, or wrapping the whole app in one giant provider that re-renders on every keystroke. React ships with everything you need for most cases — useState, useReducer, and the Context API — and only a subset of apps genuinely need an external library. This lesson gives you a decision framework: start local, colocate state, reach for Context + useReducer for shared logic, and escalate to Zustand or Redux only when the problem demands it.

Learning Objectives
  • Decide where state should live: local, lifted, or shared via Context
  • Model complex state transitions with useReducer instead of tangled useState calls
  • Combine Context + useReducer to share state without prop drilling
  • Prevent unnecessary re-renders by splitting state and dispatch into separate contexts
  • Know the concrete signals that justify adopting Zustand, Redux, or Jotai

Where Should State Live?

The cheapest state is the state you never share. Before reaching for any global tool, apply this order of preference:

  1. Local state (useState) — owned by a single component. Default choice.
  2. Lifted state — move it to the closest common ancestor of the components that need it, and pass it down via props.
  3. Shared state (Context) — only when the same value is needed by many components across distant branches of the tree.
tsx
// Local: this counter is nobody else's business. function LikeButton() { const [likes, setLikes] = useState(0) return <button onClick={() => setLikes((n) => n + 1)}>{likes} likes</button> }

Prop drilling — passing a value through intermediate components that don't use it — is the smell that tells you a value has outgrown lifting and belongs in Context.

Modeling State with useReducer

When state updates involve multiple sub-values or follow rules (add, remove, toggle, reset), a pile of useState calls becomes hard to keep consistent. useReducer centralizes every transition in one pure function, so the how of updating lives in one place and components only describe what happened by dispatching actions.

tsx
type CartItem = { id: string; name: string; price: number; qty: number } type CartState = { items: CartItem[] } type CartAction = | { type: "ADD"; item: CartItem } | { type: "REMOVE"; id: string } | { type: "CLEAR" } function cartReducer(state: CartState, action: CartAction): CartState { switch (action.type) { case "ADD": { const existing = state.items.find((i) => i.id === action.item.id) if (existing) { return { items: state.items.map((i) => i.id === action.item.id ? { ...i, qty: i.qty + action.item.qty } : i ), } } return { items: [...state.items, action.item] } } case "REMOVE": return { items: state.items.filter((i) => i.id !== action.id) } case "CLEAR": return { items: [] } default: return state } }

Sharing State with the Context API

Context lets any descendant read a value without it being threaded through props. Pairing Context with useReducer gives you a mini store: the provider holds the state, and consumers dispatch actions to change it.

tsx
const CartContext = createContext<{ state: CartState dispatch: React.Dispatch<CartAction> } | null>(null) export function CartProvider({ children }: { children: React.ReactNode }) { const [state, dispatch] = useReducer(cartReducer, { items: [] }) const value = useMemo(() => ({ state, dispatch }), [state]) return <CartContext.Provider value={value}>{children}</CartContext.Provider> }

Wrap the reads in a custom hook so consumers get a friendly error instead of a cryptic null crash when they forget the provider:

tsx
export function useCart() { const ctx = useContext(CartContext) if (!ctx) { throw new Error("useCart must be used within a <CartProvider>") } return ctx }

Now any component can call const { state, dispatch } = useCart() — no prop drilling, and TypeScript knows the shape.

Avoiding Re-render Storms with Split Contexts

A subtle Context pitfall: every consumer re-renders whenever the context value changes, even consumers that only need dispatch (which never changes). If your provider's value is { state, dispatch }, a component that only dispatches will still re-render on every state update. The fix is to split state and dispatch into two separate contexts.

tsx
const CartStateContext = createContext<CartState | null>(null) const CartDispatchContext = createContext<React.Dispatch<CartAction> | null>(null) export function CartProvider({ children }: { children: React.ReactNode }) { const [state, dispatch] = useReducer(cartReducer, { items: [] }) return ( <CartStateContext.Provider value={state}> <CartDispatchContext.Provider value={dispatch}> {children} </CartDispatchContext.Provider> </CartStateContext.Provider> ) }

Because dispatch is guaranteed stable across renders, a <AddToCartButton> that only consumes CartDispatchContext never re-renders when items change — only components reading CartStateContext do.

When to Escalate to an External Library

Context is a dependency-injection mechanism, not a performance-optimized store. When a single context holds fast-changing state read by hundreds of components, you'll fight re-renders. That is the signal to adopt a purpose-built library.

Built in, zero dependencies. Best for low-frequency shared state: theme, current user, locale, a shopping cart. Split contexts to control re-renders.

A Zustand store needs no provider and subscribes by selector:

ts
import { create } from "zustand" type CartStore = { items: CartItem[] add: (item: CartItem) => void clear: () => void } export const useCartStore = create<CartStore>((set) => ({ items: [], add: (item) => set((s) => ({ items: [...s.items, item] })), clear: () => set({ items: [] }), })) // Re-renders ONLY when items.length changes, not on unrelated updates. function CartBadge() { const count = useCartStore((s) => s.items.length) return <span>{count}</span> }

Best Practices & Common Pitfalls


Next Steps

Continue Learning
Master useEffect, useMemo, and custom hooks.

Next Lesson

Hooks Deep Dive