Skip to content
React rc state-management 5 min read

The Reducer + Context Pattern

When your app outgrows a handful of useState calls but a full Redux setup feels heavy, React gives you everything you need to build a small global store with no dependencies. By pairing useReducer for predictable state transitions with Context for distribution, you get a single source of truth, centralized update logic, and components anywhere in the tree that can read state or dispatch actions. This page shows the pattern end to end, including the split-context optimization that keeps re-renders in check.

Why combine useReducer with Context

useReducer consolidates all the ways your state can change into one pure reducer function. Every update flows through a single dispatch call, which makes transitions easy to reason about, test, and trace. The catch is that useReducer is local to one component — its state lives only where the hook is called.

Context solves the distribution problem. By placing the reducer’s state and dispatch into providers near the top of the tree, any descendant can subscribe without prop drilling. The reducer handles how state changes; Context handles where state is available. Together they form a tiny store that mirrors the core ideas behind Redux without the boilerplate or extra package.

Defining the reducer and actions

Start with the state shape, action constants, and a pure reducer. Keeping action creators as small functions gives you a typed, discoverable surface area and a single place to fix payload shapes.

// store/cartReducer.js
export const initialState = {
  items: [],
  discount: 0,
};

export const ACTIONS = {
  ADD_ITEM: "add_item",
  REMOVE_ITEM: "remove_item",
  APPLY_DISCOUNT: "apply_discount",
  CLEAR: "clear",
};

export function cartReducer(state, action) {
  switch (action.type) {
    case ACTIONS.ADD_ITEM: {
      const existing = state.items.find((i) => i.id === action.payload.id);
      const items = existing
        ? state.items.map((i) =>
            i.id === action.payload.id ? { ...i, qty: i.qty + 1 } : i
          )
        : [...state.items, { ...action.payload, qty: 1 }];
      return { ...state, items };
    }
    case ACTIONS.REMOVE_ITEM:
      return {
        ...state,
        items: state.items.filter((i) => i.id !== action.payload),
      };
    case ACTIONS.APPLY_DISCOUNT:
      return { ...state, discount: action.payload };
    case ACTIONS.CLEAR:
      return initialState;
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

// Action creators: keep call sites clean and payload shapes consistent.
export const addItem = (product) => ({ type: ACTIONS.ADD_ITEM, payload: product });
export const removeItem = (id) => ({ type: ACTIONS.REMOVE_ITEM, payload: id });
export const applyDiscount = (pct) => ({ type: ACTIONS.APPLY_DISCOUNT, payload: pct });
export const clearCart = () => ({ type: ACTIONS.CLEAR });

Splitting state and dispatch into two contexts

A common mistake is putting { state, dispatch } into a single context object. Because dispatch has a stable identity across renders but state does not, bundling them means every consumer re-renders whenever either changes — even components that only dispatch. The fix is to expose two contexts: one for state and one for dispatch.

// store/CartContext.jsx
import { createContext, useContext, useReducer } from "react";
import { cartReducer, initialState } from "./cartReducer";

const CartStateContext = createContext(null);
const CartDispatchContext = createContext(null);

export function CartProvider({ children }) {
  const [state, dispatch] = useReducer(cartReducer, initialState);

  return (
    <CartStateContext.Provider value={state}>
      <CartDispatchContext.Provider value={dispatch}>
        {children}
      </CartDispatchContext.Provider>
    </CartStateContext.Provider>
  );
}

export function useCartState() {
  const ctx = useContext(CartStateContext);
  if (ctx === null) throw new Error("useCartState must be used within CartProvider");
  return ctx;
}

export function useCartDispatch() {
  const ctx = useContext(CartDispatchContext);
  if (ctx === null) throw new Error("useCartDispatch must be used within CartProvider");
  return ctx;
}

The custom hooks do double duty: they hide useContext, and they throw a clear error if a component is rendered outside the provider — far better than a silent null crash deep in your code.

Tip: dispatch is guaranteed stable for the lifetime of the component, so it is safe to list in useEffect and useCallback dependency arrays without causing extra runs.

Wiring it together

Wrap the part of the tree that needs the store, then read state and dispatch independently.

// App.jsx
import { CartProvider } from "./store/CartContext";
import { ProductList } from "./ProductList";
import { CartSummary } from "./CartSummary";

export default function App() {
  return (
    <CartProvider>
      <ProductList />
      <CartSummary />
    </CartProvider>
  );
}
// ProductList.jsx — dispatches only, so it does NOT re-render on state changes
import { useCartDispatch } from "./store/CartContext";
import { addItem } from "./store/cartReducer";

const catalog = [
  { id: "p1", name: "Keyboard", price: 80 },
  { id: "p2", name: "Mouse", price: 40 },
];

export function ProductList() {
  const dispatch = useCartDispatch();
  return (
    <ul>
      {catalog.map((p) => (
        <li key={p.id}>
          {p.name} — ${p.price}
          <button onClick={() => dispatch(addItem(p))}>Add</button>
        </li>
      ))}
    </ul>
  );
}
// CartSummary.jsx — reads state and dispatches
import { useCartState, useCartDispatch } from "./store/CartContext";
import { removeItem, clearCart } from "./store/cartReducer";

export function CartSummary() {
  const { items, discount } = useCartState();
  const dispatch = useCartDispatch();

  const subtotal = items.reduce((sum, i) => sum + i.price * i.qty, 0);
  const total = subtotal * (1 - discount);

  return (
    <section>
      <h2>Cart ({items.length} lines)</h2>
      {items.map((i) => (
        <div key={i.id}>
          {i.name} × {i.qty}
          <button onClick={() => dispatch(removeItem(i.id))}>Remove</button>
        </div>
      ))}
      <p>Total: ${total.toFixed(2)}</p>
      <button onClick={() => dispatch(clearCart())}>Clear</button>
    </section>
  );
}

After adding a keyboard and a mouse, then removing the mouse, the summary renders:

Output:

Cart (1 lines)
Keyboard × 1
Total: $80.00

When this pattern is enough

This pattern covers a large share of real apps. Reach for a dedicated library only when you outgrow what plain React offers.

ConcernReducer + ContextRedux Toolkit / Zustand
Small to medium global stateIdealOverkill
Zero extra dependenciesYesNo
Built-in DevTools / time travelNoYes
Middleware, async pipelinesManualBuilt in
Selector-based render bailoutManual (split contexts)Built in
Many high-frequency updatesCan re-render widelyOptimized

Gotcha: Context has no built-in selector mechanism. Every consumer of a state context re-renders on any state change. Splitting contexts by domain (cart, auth, theme) and separating state from dispatch mitigates this, but if a single context drives frequent, fine-grained updates, a store with selectors like Zustand or Redux Toolkit will scale better.

Best practices

  • Split state and dispatch into separate contexts so dispatch-only components never re-render on state changes.
  • Wrap each context in a custom hook that throws when used outside its provider.
  • Keep the reducer pure — no fetches, timers, or mutations; return new objects with the spread operator.
  • Use action creators to centralize action type strings and payload shapes, and adopt TypeScript discriminated unions for full type safety.
  • Scope providers to the subtree that needs them rather than wrapping the entire app in one giant store.
  • Split unrelated concerns into separate provider pairs instead of one monolithic context.
  • Move to Redux Toolkit, Zustand, or Jotai when you need middleware, selectors, DevTools, or high-frequency granular updates.
Last updated June 14, 2026
Was this helpful?