How to Optimize useReducer in React

The useReducer hook is a powerful tool for managing complex state in React, but poor implementation can lead to unnecessary re-renders and performance bottlenecks. This article provides a straightforward guide on how to optimize useReducer in React, focusing on techniques like defining reducers outside the component, implementing lazy initialization, splitting state and dispatch contexts, and preventing redundant state updates.

1. Define the Reducer Function Outside the Component

One of the simplest yet most overlooked optimizations is the placement of the reducer function.

If you define the reducer function inside your React component, it is recreated on every single render. This consumes unnecessary memory and can cause optimization tools like React.memo to fail if the reducer is passed down as a prop.

// Avoid this:
function MyComponent() {
  const reducer = (state, action) => { ... };
  const [state, dispatch] = useReducer(reducer, initialState);
}

// Do this:
const reducer = (state, action) => { ... };

function MyComponent() {
  const [state, dispatch] = useReducer(reducer, initialState);
}

By declaring the reducer outside the component scope, the function is created only once when the module loads.

2. Use Lazy Initialization for Expensive Initial State

If your initial state requires a heavy calculation (such as reading from localStorage or filtering a massive array), do not pass it directly as the second argument. Doing so will run the expensive calculation on every render, even though React only uses the initial state on the first mount.

Instead, use the third argument of useReducer (the init function) to calculate the state lazily.

// Expensive calculation helper
const initializeState = (initialCount) => {
  return { count: initialCount + performHeavyComputation() };
};

function MyComponent({ initialCount }) {
  // The initializeState function only runs once on mount
  const [state, dispatch] = useReducer(reducer, initialCount, initializeState);
}

3. Prevent Unnecessary Re-renders with Split Contexts

When passing state and dispatch down to deeply nested child components, wrapping them in a single Context provider can cause performance issues. Any update to the state will force all components consuming that context to re-render, even if they only need the dispatch function.

To optimize this, split your context into two: one for state and one for dispatch. Since React guarantees that the dispatch function reference never changes, components consuming the dispatch-only context will never re-render when the state changes.

const StateContext = React.createContext();
const DispatchContext = React.createContext();

function StateProvider({ children }) {
  const [state, dispatch] = useReducer(reducer, initialState);

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

4. Bail Out of State Updates Early

React determines whether to re-render a component by using the Object.is comparison algorithm on the state. If your reducer returns a new object reference on an action that didn’t actually change any data, React will still trigger a re-render.

Always check if the incoming action requires a change. If it does not, return the existing state object directly to bail out of the render phase.

const reducer = (state, action) => {
  switch (action.type) {
    case 'SET_ACTIVE':
      // If the value is already active, return the unchanged state reference
      if (state.isActive === true) {
        return state; 
      }
      return { ...state, isActive: true };
    default:
      return state;
  }
};