Context API and useReducer

· 7 min read · Updated March 27, 2026 · intermediate
javascript react context useReducer state-management

Context API and useReducer are two React features that solve distinct problems. Context solves the prop drilling problem — passing data through components that don’t need it just to reach the ones that do. useReducer solves the tangled state problem — when multiple state transitions happen at once and useState starts feeling messy. Together they form a lightweight state management system without any external libraries.

The Prop Drilling Problem

Imagine a user object that lives at your app’s root and is needed five levels deep in a settings panel. Without Context, you pass it as a prop through four intermediate components that never touch it:

function App() {
  return <Dashboard user={currentUser} />;
}

function Dashboard({ user }) {
  return (
    <Layout>
      <Sidebar user={user} />
      <Main user={user} />
    </Layout>
  );
}

function Sidebar({ user }) {
  return <UserAvatar user={user} />;
}

function Main({ user }) {
  return <SettingsPanel user={user} />;
}

function SettingsPanel({ user }) {
  return <p>{user.name}</p>;
}

This is prop drilling. The Sidebar and Main components don’t care about user — they just pass it along. Change the prop name or shape, and you update every intermediate component. Context fixes this by creating a named channel that any component can subscribe to directly.

Creating and Providing Context

The createContext function creates a context object with a Provider component. Wrap any subtree with the Provider to make a value available to all descendants:

import { createContext, useContext } from 'react';

const UserContext = createContext(null);

function App() {
  return (
    <UserContext.Provider value={currentUser}>
      <Dashboard />
    </UserContext.Provider>
  );
}

createContext takes a single argument: the default value. React uses this when there’s no matching Provider above the component that calls useContext. The default value is static — it never changes, so treat it as a fallback.

Any component inside the Provider tree can read the value with useContext:

function SettingsPanel() {
  const user = useContext(UserContext);
  return <p>{user.name}</p>;
}

The useContext hook returns the context value from the closest Provider above the component in the tree. No prop threading required.

Reading Multiple Contexts

A component can read from more than one context. Each useContext call is independent:

const ThemeContext = createContext('light');
const UserContext = createContext(null);

function Header() {
  const theme = useContext(ThemeContext);
  const user = useContext(UserContext);

  return (
    <header className={theme}>
      {user ? <Avatar src={user.avatar} /> : <LoginLink />}
    </header>
  );
}

The key thing to understand: when the Provider’s value changes reference, every component subscribed to that context re-renders. This is the most common performance footgun with Context. More on this later.

When to Reach for Context

Context is not a replacement for props. The React team’s official guidance is to prefer props and only use Context when data genuinely belongs to many components across the tree.

Good fits for Context:

  • Theme (dark/light mode)
  • User authentication state
  • Language or locale preference
  • Anything that truly applies to most components in an app

Avoid Context for:

  • Ephemeral state like form inputs or UI toggles
  • State that only one or two components need
  • State that could be composed differently with children-as-function patterns

The “God Context” anti-pattern — stuffing every piece of app state into a single context — causes every consumer to re-render whenever anything changes. Split contexts by concern.

Managing State with useReducer

useReducer is a state hook that suits complex or multi-field state. The signature:

const [state, dispatch] = useReducer(reducer, initialArg, init?);
  • state — the current state value
  • dispatch — a stable function that triggers a state update
  • reducer — a pure function: (state, action) => newState
  • initialArg — the initial state value
  • init — optional lazy initialization function

The reducer function takes the current state and an action object, then returns the next state. Because reducers are pure functions, they’re easy to test — no rendering required:

function counterReducer(state, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 };
    case 'DECREMENT':
      return { count: state.count - 1 };
    case 'RESET':
      return { count: 0 };
    default:
      return state;
  }
}

function Counter() {
  const [state, dispatch] = useReducer(counterReducer, { count: 0 });

  return (
    <div>
      <span>{state.count}</span>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
      <button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
    </div>
  );
}

The dispatch function never changes across renders. This is useful: a component that only needs to dispatch actions won’t re-render when state changes elsewhere.

Use useReducer when:

  • State consists of multiple related fields
  • Updates involve conditional logic or multiple sub-updates
  • The same action could produce different results depending on current state
  • You want to centralize state logic for testability

Combining Context and useReducer

This is where the two features complement each other. useReducer manages the state; Context distributes it. Together they form a lightweight Redux-like store:

// cartContext.js
import { createContext, useContext, useReducer } from 'react';

const CartContext = createContext(null);

function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM':
      return { ...state, items: [...state.items, action.payload] };
    case 'REMOVE_ITEM':
      return {
        ...state,
        items: state.items.filter(item => item.id !== action.payload)
      };
    case 'CLEAR_CART':
      return { ...state, items: [] };
    default:
      return state;
  }
}

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

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

export function useCart() {
  const context = useContext(CartContext);
  if (!context) {
    throw new Error('useCart must be used within a CartProvider');
  }
  return context;
}

Components consume the cart without any prop drilling:

function CartIcon() {
  const { state } = useCart();
  return <span>Cart ({state.items.length})</span>;
}

function CheckoutButton() {
  const { dispatch } = useCart();
  return (
    <button onClick={() => dispatch({ type: 'CLEAR_CART' })}>
      Checkout
    </button>
  );
}

The reducer centralizes all cart logic. State is never mutated directly — every change goes through dispatch. This makes the state transitions predictable and testable.

Performance: Keeping Re-Renders Under Control

Context re-renders catch many developers off guard. When a Provider’s value changes reference, every useContext that reads that context re-renders — regardless of whether the component actually uses the changed value.

The solution is to split contexts so each one holds only the data that changes together:

// Bad: everything re-renders on any change
<AppContext.Provider value={{ user, theme, cart, settings }}>

// Good: each concern isolated
<UserContext.Provider value={user}>
<ThemeContext.Provider value={theme}>
<CartContext.Provider value={cart}>

When cart changes, only components subscribed to CartContext re-render.

Another common issue: wrapping state and dispatch in the same object. The whole object gets a new reference on every state update, which triggers re-renders even for components that only call dispatch:

// dispatch is stable but the object changes every render
<CartContext.Provider value={{ state, dispatch }}>

// Better: split into two contexts
<CartStateContext.Provider value={state}>
<CartDispatchContext.Provider value={dispatch}>

With two separate contexts, a component that only calls dispatch doesn’t re-render when state changes.

Lazy Initialization

For expensive initial state computation, pass a function as the third argument to useReducer. React calls it only once, on the initial render:

function buildInitialStateFromStorage() {
  const saved = localStorage.getItem('cart');
  return saved ? JSON.parse(saved) : { items: [] };
}

const [state, dispatch] = useReducer(cartReducer, null, buildInitialStateFromStorage);

The init argument (third parameter) receives initialArg and returns the initial state. This keeps initialization logic out of the render path.

Testing Components with Context

A component that calls useContext won’t work correctly without a Provider in the tree. The standard approach is to wrap with the Provider in your test:

import { render, screen } from '@testing-library/react';
import { CartProvider, useCart } from './cartContext';

function TestCartDisplay() {
  const { state } = useCart();
  return <p>{state.items.length} items</p>;
}

test('displays item count', () => {
  render(
    <CartProvider>
      <TestCartDisplay />
    </CartProvider>
  );
  expect(screen.getByText('0 items')).toBeInTheDocument();
});

The reducer itself can be tested in complete isolation — no rendering required:

test('ADD_ITEM appends to items array', () => {
  const state = { items: [] };
  const action = { type: 'ADD_ITEM', payload: { id: 1, name: 'Widget' } };
  const result = cartReducer(state, action);
  expect(result.items).toHaveLength(1);
  expect(result.items[0].name).toBe('Widget');
});

Because reducers are pure functions, they never surprise you in tests.

See Also

Written

  • File: sites/jsguides/src/content/tutorials/react-context-and-reducers.md
  • Words: ~1150
  • Read time: 6 min
  • Topics covered: Context API (createContext, useContext, Provider), useReducer (reducer pattern, dispatch, lazy init), combining Context + useReducer, performance (split contexts, stable dispatch), testing with Provider wrappers
  • Verified via: react.dev/reference/react/createContext, react.dev/reference/react/useContext, react.dev/reference/react/useReducer
  • Unverified items: none