Skip to content

Why is My React Reducer Called Twice and What the Heck is a Pure Function?

Why is My React Reducer Called Twice and What the Heck is a Pure Function?

Why is My React Reducer Called Twice and What the Heck is a Pure Function?

In a recent project, we encountered an interesting issue: our React reducer was dispatching twice, producing incorrect values, such as incrementing a number in increments of two.

We hopped on a pairing session and started debugging. Eventually, we got to the root of the problem and learned the importance of pure functions in functional programming. This article will explain why our reducer was being dispatched twice, what pure functions are, and how React's strict mode helped us identify a bug in our code.

The Issue

We noticed that our useReducer hook was causing the reducer function to be called twice for every action dispatched. Initially, we were confused about this behavior and thought it might be a bug in React. Additionally, we had one of the dispatches inside a useEffect, which caused it to be called twice due to React strict mode, effectively firing the reducer four times and further complicating our debugging process. However, we knew that React's strict mode caused useEffect to be called twice, so it didn't take very long to realize that the issue was not with React but with how we had implemented our reducer function.

React Strict Mode

React's strict mode is a tool for highlighting potential problems in an application. It intentionally double-invokes specific lifecycle methods and hooks (like useReducer and useEffect) to help developers identify side effects. This behavior exposed our issue, as we had reducers that were not pure functions.

What is a Pure Function?

A pure function is a function that:

  • Is deterministic: Given the same input, always returns the same output.
  • Does Not Have Side Effects: Does not alter any external state or have observable interactions with the outside world.

In the context of a reducer, this means the function should not:

  • Modify its arguments
  • Perform any I/O operations (like network requests or logging)
  • Generate random numbers
  • Depend on any external state

Pure functions are predictable and testable. They help prevent bugs and make code easier to reason about. In the context of React, pure functions are essential for reducers because they ensure that the state transitions are predictable and consistent.

The Root Cause: Impure Reducers

Our reducers were not pure functions. They were altering external state and had side effects, which caused inconsistent behavior when React's strict mode double-invoked them. This led to unexpected results and made debugging more difficult.

The Solution: Make Reducers Pure

To resolve this issue, we refactored our reducers to ensure they were pure functions. Here's an extended example of how we transformed an impure reducer into a pure one in a more complex scenario involving a task management application.

Let's start with the initial state and action types:

const initialState = {
  tasks: [],
  completedTasks: [],
  currentTaskIndex: 0,
};

const ActionTypes = {
  ADD_TASK: "ADD_TASK",
  COMPLETE_TASK: "COMPLETE_TASK",
  RESET_TASKS: "RESET_TASKS",
  NEXT_TASK: "NEXT_TASK",
};

And here's the impure reducer similar to what we had initially:

import { initialState } from "./reducer-states";

export function reducer(state, action) {
  switch (action.type) {
    case "ADD_TASK":
      state.tasks.push(action.payload); // Direct state modification
      return { ...state };

    case "COMPLETE_TASK":
      state.completedTasks.push(state.tasks[state.currentTaskIndex]); // Direct state modification
      state.tasks.splice(state.currentTaskIndex, 1); // Direct state modification
      return { ...state };

    case "RESET_TASKS":
      return {
        ...state,
 tasks: initialState.tasks,
 completedTasks: initialState.completedTasks,
 currentTaskIndex: initialState.currentTaskIndex,
      };

    case "NEXT_TASK":
      state.currentTaskIndex =
 (state.currentTaskIndex + 1) % state.tasks.length; // Direct state modification
      return { ...state };

    default:
      return state;
  }
}

This reducer is impure because it directly modifies the state object, which is a side effect. To make it pure, we must create a new state object for every action and return it without modifying the original state. Here's the refactored pure reducer:

import { initialState } from "./reducer-states";

export function reducer(state, action) {
  switch (action.type) {
    case "ADD_TASK":
      return {
        ...state,
 tasks: [...state.tasks, action.payload],
      };

    case "COMPLETE_TASK":
      const completedTask = state.tasks[state.currentTaskIndex];
      return {
        ...state,
 tasks: state.tasks.filter(
          (_, index) => index !== state.currentTaskIndex
 ),
 completedTasks: [...state.completedTasks, completedTask],
 currentTaskIndex:
          state.tasks.length > 1
            ? state.currentTaskIndex % (state.tasks.length - 1)
            : 0,
      };

    case "RESET_TASKS":
      return {
        ...state,
 tasks: initialState.tasks,
 completedTasks: initialState.completedTasks,
 currentTaskIndex: initialState.currentTaskIndex,
      };

    case "NEXT_TASK":
      return {
        ...state,
 currentTaskIndex: (state.currentTaskIndex + 1) % state.tasks.length,
      };

    default:
      return state;
  }
}

Key Changes:

  • Direct State Modification: In the impure reducer, the state is directly modified (e.g., state.tasks.push(action.payload)). This causes side effects and violates the principles of pure functions.
  • Side Effects: The impure reducer included side effects such as logging and direct state changes. The pure reducer eliminates these side effects, ensuring consistent and predictable behavior.

I've created an interactive example to demonstrate the difference between impure and pure reducers in a React application. Despite the RESET_TASKS action being implemented similarly in both reducers, you'll notice that the impure reducer does not reset the tasks correctly. This problem happens because the impure reducer directly modifies the state, leading to unexpected behavior. Check out the embedded StackBlitz example below:

Conclusion

Our experience with the reducer dispatching twice was a valuable lesson in the importance of pure functions in React. Thanks to React's strict mode, we identified and fixed impure reducers, leading to more predictable and maintainable code. If you encounter similar issues, ensure your reducers are pure functions and leverage React strict mode to catch potential problems early in development. By embracing functional programming principles, you can write cleaner, more reliable code that is easier to debug and maintain.

This Dot is a consultancy dedicated to guiding companies through their modernization and digital transformation journeys. Specializing in replatforming, modernizing, and launching new initiatives, we stand out by taking true ownership of your engineering projects.

We love helping teams with projects that have missed their deadlines or helping keep your strategic digital initiatives on course. Check out our case studies and our clients that trust us with their engineering.