Programming Intermediate 12 min

How to Manage Global State in React with Context + useReducer

Context solves one specific problem: getting state into deeply nested components without threading props through every layer in between. It is not a state management library — it is a dependency injection mechanism. Used correctly with useReducer, it handles cross-tree state like the current user, theme, or shopping cart. Used incorrectly, it causes every component in your tree to re-render on every state change. This guide shows you both the right and wrong way.

Step-by-step

  1. 1

    Know when Context is the right tool

    Context is appropriate when:

    • State needs to be read by components at different nesting levels (theme, locale, current user, auth token, cart)
    • Prop drilling is already painful — you are passing the same prop through 3+ layers that do not use it themselves

    Context is not appropriate when:

    • State changes frequently (every keystroke, scroll event, animation frame) — see the warning in the last step
    • State is local to one subtree — just pass props or lift state
  2. 2

    Create two separate contexts

    The most common Context mistake is putting both the state value and the dispatch function in a single context. Any component that consumes it will re-render whenever state changes, even if it only needs to dispatch actions. Split them from the start.

    jsx
    import { createContext } from 'react';
    
    // Consumers of this context re-render when state changes
    export const CartStateContext    = createContext(null);
    
    // Consumers of this context NEVER re-render due to state changes
    // (dispatch is stable — React guarantees it never changes)
    export const CartDispatchContext = createContext(null);
  3. 3

    Define the reducer

    A reducer is a pure function: (state, action) => newState. It should never mutate state directly — always return a new object. Keep it in its own file so it is easy to test.

    javascript
    // cart-reducer.js
    export const initialState = { items: [], total: 0 };
    
    export function cartReducer(state, action) {
      switch (action.type) {
        case 'ADD_ITEM': {
          const exists = state.items.find(i => i.id === action.item.id);
          const items  = exists
            ? state.items.map(i =>
                i.id === action.item.id
                  ? { ...i, qty: i.qty + 1 }
                  : i
              )
            : [...state.items, { ...action.item, qty: 1 }];
          return { items, total: items.reduce((s, i) => s + i.price * i.qty, 0) };
        }
        case 'REMOVE_ITEM':
          return {
            ...state,
            items: state.items.filter(i => i.id !== action.id),
            total: state.total - (state.items.find(i => i.id === action.id)?.price ?? 0),
          };
        case 'CLEAR':
          return initialState;
        default:
          return state;
      }
    }
  4. 4

    Build the Provider component

    The Provider wraps your app (or the subtree that needs the state) and owns the useReducer call. It provides the state and dispatch through their separate contexts. This is the only place useReducer is called.

    jsx
    import { useReducer } from 'react';
    import { CartStateContext, CartDispatchContext } from './cart-context';
    import { cartReducer, initialState } from './cart-reducer';
    
    export function CartProvider({ children }) {
      const [state, dispatch] = useReducer(cartReducer, initialState);
    
      return (
        <CartDispatchContext.Provider value={dispatch}>
          <CartStateContext.Provider value={state}>
            {children}
          </CartStateContext.Provider>
        </CartDispatchContext.Provider>
      );
    }
  5. 5

    Wrap the app with the Provider

    Mount the Provider at the highest point in the tree that needs access to this state. Usually that is near the app root, but not necessarily — if only the checkout flow needs the cart, wrap only that subtree.

    jsx
    // main.jsx or App.jsx
    import { CartProvider } from './cart-provider';
    
    function App() {
      return (
        <CartProvider>
          <Header />
          <main>
            <ProductList />
            <CheckoutFlow />
          </main>
        </CartProvider>
      );
    }
  6. 6

    Consume state and dispatch in children

    Components that read state consume CartStateContext. Components that only fire actions consume CartDispatchContext. An "Add to Cart" button only needs dispatch — it will never re-render because state changed.

    jsx
    import { useContext } from 'react';
    import { CartStateContext, CartDispatchContext } from './cart-context';
    
    // Read-only: re-renders when cart state changes
    function CartSummary() {
      const { items, total } = useContext(CartStateContext);
      return (
        <div>
          <span>{items.length} items</span>
          <span>${total.toFixed(2)}</span>
        </div>
      );
    }
    
    // Write-only: never re-renders due to cart state changes
    function AddToCartButton({ product }) {
      const dispatch = useContext(CartDispatchContext);
      return (
        <button onClick={() => dispatch({ type: 'ADD_ITEM', item: product })}>
          Add to cart
        </button>
      );
    }
  7. 7

    Create custom hooks for cleaner API

    Exporting the raw contexts forces every consumer to import two files and call useContext directly. Wrap them in named hooks. This also lets you add a guard that throws a useful error if the hook is used outside its Provider.

    javascript
    import { useContext } from 'react';
    import { CartStateContext, CartDispatchContext } from './cart-context';
    
    export function useCartState() {
      const ctx = useContext(CartStateContext);
      if (ctx === null) throw new Error('useCartState must be used inside <CartProvider>');
      return ctx;
    }
    
    export function useCartDispatch() {
      const ctx = useContext(CartDispatchContext);
      if (ctx === null) throw new Error('useCartDispatch must be used inside <CartProvider>');
      return ctx;
    }
    
    // Usage anywhere in the tree:
    // const { items } = useCartState();
    // const dispatch  = useCartDispatch();
  8. 8

    Know when Context is the wrong choice

    Context re-renders every consuming component when its value changes. For state that changes many times per second — a form field on each keystroke, an animation, a mouse position — this causes cascading re-renders that cannot be fixed with useMemo or React.memo alone. For high-frequency state, use a dedicated library:

    • Zustand — minimal, no boilerplate, subscription-based (only subscribed components re-render)
    • Jotai — atomic state, fine-grained subscriptions
    • Redux Toolkit — worth it at large scale with strict team patterns

    Context is the right choice for state that changes rarely: auth, theme, locale, feature flags.

Tips & gotchas

  • The split-context pattern (value context vs dispatch context) is the single most impactful Context optimization. Apply it every time.
  • `React.memo` wraps a component and prevents re-rendering if its props have not changed — combine it with the split-context pattern for maximum effect.
  • Multiple small contexts beat one large context. A `ThemeContext`, `AuthContext`, and `CartContext` are better than a single `AppContext` holding everything.
  • You can nest Providers freely. A `CartProvider` inside `AuthProvider` inside `ThemeProvider` is a perfectly valid pattern.
  • Do not put functions into context values unless they are stable (wrapped in `useCallback`). A new function reference on every render makes the context value look "changed" and triggers re-renders.

Wrapping up

Context + useReducer is a lightweight, built-in solution for global state that does not require installing anything. The key discipline is splitting state and dispatch into separate contexts, defining a clean reducer, and knowing the boundary — state that changes rarely is a great fit; state that changes constantly is not. For everything beyond that boundary, Zustand or Redux Toolkit are the right next step.

#React #State #Context
Back to all guides

Need Help With Your Project?

Book a free 30-minute consultation to discuss your technical challenges and explore solutions together.