How to combine a reducer with context in React?

Combining a reducer with context in React isn’t just about organizing state—it’s about crafting an architecture that scales gracefully. When my project grew beyond simple prop drilling and I found myself passing callbacks three or four levels deep, I realized that Context alone wasn’t cutting it. It could provide global access, sure, but managing complex state transitions without some sort of structure was quickly becoming painful. That’s when I started combining useReducer with Context, and it changed the way I built component hierarchies.

Why combine them

Reducers bring structure to state updates—especially when the state involves multiple properties or event-driven changes. Context lets that structured state and its update functions be shared globally. Separately, they’re useful. Together, they solve an entire class of problems that neither can handle alone. When I switched from useState to useReducer, I immediately gained clarity on how my state changed in response to user actions. Wrapping that reducer inside a Context meant any component could access or trigger those changes without relying on a waterfall of props.

Creating the reducer

I started by writing a plain reducer function—just a simple one to manage a theme toggle:

const themeReducer = (state, action) => {
  switch (action.type) {
    case 'TOGGLE_THEME':
      return { dark: !state.dark };
    default:
      return state;
  }
};

It felt intuitive. Instead of maintaining multiple flags, I had one clear place controlling transitions. I noticed the more types I added, the easier it became to trace what caused a particular UI change.

Building the context

Next, I created a context. Two, actually: one for state and one for dispatch. This separation became a subtle but powerful pattern. It allowed components that only needed to trigger an action to avoid unnecessary re-renders.

const ThemeStateContext = React.createContext();
const ThemeDispatchContext = React.createContext();

In my initial attempts, I combined both into a single context. But I noticed components re-rendered even when they didn’t care about the state. That small shift—splitting state and dispatch—was a performance win that I still rely on in larger apps.

Creating the provider

The provider tied it all together. It initialized the reducer and wrapped my app with the context providers:

export const ThemeProvider = ({ children }) => {
  const [state, dispatch] = useReducer(themeReducer, { dark: false });

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

This layer gave me full control over what gets exposed globally. I sometimes add helper functions or memoize complex calculations here, depending on what the context needs to do.

Using the context

Consuming this context became effortless, especially once I added custom hooks. These hooks helped me avoid repeating context calls and gave clear names to what each part of the code was doing:

export const useThemeState = () => useContext(ThemeStateContext);
export const useThemeDispatch = () => useContext(ThemeDispatchContext);

Now, instead of cluttering my components with context imports and checks, I simply use these hooks:

const dispatch = useThemeDispatch();
dispatch({ type: 'TOGGLE_THEME' });

I’ve used this same pattern in more complex systems too—things like form builders, product configurations, even quiz engines like this one that needed layered state interactions.

Keeping code maintainable

The more I built with this setup, the more reusable and testable my logic became. Reducers made unit testing logic straightforward—pass state and action, assert the result. Context removed my need for tightly coupled parent-child relationships. I wasn’t writing as many wrapper components just to bridge props across the tree.

As the state logic grew, sometimes I extracted action creators to reduce duplication. For example:

const toggleTheme = (dispatch) => dispatch({ type: 'TOGGLE_THEME' });

In teams, this became especially valuable. Having a centralized place to look at all available actions made onboarding easier and bugs less mysterious.

Challenges I faced

There were a few hiccups. I once forgot to wrap a component tree with the provider and spent an hour debugging a useContext error. Another time, I dispatched actions to the wrong context. These mistakes taught me to rely on types and conventions—naming context and provider files consistently made a big difference.

Also, when I worked on a Storybook + Material UI setup, combining useReducer with context helped keep theme and component configuration in sync across both environments.

When this pattern shines

Any time the app’s state is shared across many components and involves more than one or two flags, this combo is the right choice. Even in smaller projects, like ones involving form state or conditional rendering, it prevents complexity from creeping in.

If you’re building an app that scales—even just slightly—this setup acts like guardrails. It enforces separation of concerns between business logic and presentation. That clarity becomes your long-term productivity booster.

Final thoughts

Learning how to combine a reducer with context was one of those small shifts that changed how I structure React apps. It’s not the only solution—but it’s the one I come back to when I want predictable, testable, and shared state that doesn’t fight the component model.

I’ve experimented with newer patterns like useActionState too, and while they’re exciting for form handling and async work, this reducer-context combo remains a cornerstone for global app state. It’s simple, yet scalable—a trait I’ve come to value more as complexity grows.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top