useReducer Action dispatched twice

Scenario

I have a custom hook that returns an action. The parent component "Container" utilized the custom hook and pass the action as prop to a children component.

Problem

When the action is executed from a child component, the actual dispatch occurs twice. Now, if the children utilize the hook directly and invoked the action, the dispatch occurs only once.

How to reproduce it:

Open the below sandbox and open devtools on chrome so you can see the console logs I've added.

https://codesandbox.io/s/j299ww3lo5?fontsize=14

Main.js (children component) you will see we invoke props.actions.getData()

On the DevTools, clear the Logs. On the Preview, enter any value on the form and click the button. On the console log, you will see the actions like redux-logger and you will notice STATUS_FETCHING action is executed twice without changing the state.

Now go to Main.js and comment out line 9 and uncomment line 10. We are now basically consuming the custom hook directly.

On the DevTools, clear the Logs. On the Preview, enter any value on the form and click the button. On the console log, now you will see STATUS_FETCHING has executed only once and the state changes accordingly.

While there is no evident perf penalty as it is, I fail to understand WHY is it happening. I may be too focused on the Hooks and I'm missing something so silly...please release me from this puzzle. Thanks!


Solution 1:

To first clarify the existing behavior, the STATUS_FETCHING action was actually only being "dispatched" (i.e. if you do a console.log right before the dispatch call in getData within useApiCall.js) once, but the reducer code was executing twice.

I probably wouldn't have known what to look for to explain why if it hadn't been for my research when writing this somewhat-related answer: React hook rendering an extra time.

You'll find the following block of code from React shown in that answer:

  var currentState = queue.eagerState;
  var _eagerState = _eagerReducer(currentState, action);
  // Stash the eagerly computed state, and the reducer used to compute
  // it, on the update object. If the reducer hasn't changed by the
  // time we enter the render phase, then the eager state can be used
  // without calling the reducer again.
  _update2.eagerReducer = _eagerReducer;
  _update2.eagerState = _eagerState;
  if (is(_eagerState, currentState)) {
    // Fast path. We can bail out without scheduling React to re-render.
    // It's still possible that we'll need to rebase this update later,
    // if the component re-renders for a different reason and by that
    // time the reducer has changed.
    return;
  }

In particular, notice the comments indicating React may have to redo some of the work if the reducer has changed. The issue is that in your useApiCallReducer.js you were defining your reducer inside of your useApiCallReducer custom hook. This means that on a re-render, you provide a new reducer function each time even though the reducer code is identical. Unless your reducer needs to use arguments passed to the custom hook (rather than just using the state and action arguments passed to the reducer), you should define the reducer at the outer level (i.e. not nested inside another function). In general, I would recommend avoiding defining a function nested within another unless it actually uses variables from the scope it is nested within.

When React sees the new reducer after the re-render, it has to throw out some of the work it did earlier when trying to determine whether a re-render would be necessary because your new reducer might produce a different result. This is all just part of performance optimization details in the React code that you mostly don't need to worry about, but it is worth being aware that if you redefine functions unnecessarily, you may end up defeating some performance optimizations.

To solve this I changed the following:

import { useReducer } from "react";
import types from "./types";

const initialState = {
  data: [],
  error: [],
  status: types.STATUS_IDLE
};

export function useApiCallReducer() {
  function reducer(state, action) {
    console.log("prevState: ", state);
    console.log("action: ", action);
    switch (action.type) {
      case types.STATUS_FETCHING:
        return {
          ...state,
          status: types.STATUS_FETCHING
        };
      case types.STATUS_FETCH_SUCCESS:
        return {
          ...state,
          error: [],
          data: action.data,
          status: types.STATUS_FETCH_SUCCESS
        };
      case types.STATUS_FETCH_FAILURE:
        return {
          ...state,
          error: action.error,
          status: types.STATUS_FETCH_FAILURE
        };
      default:
        return state;
    }
  }
  return useReducer(reducer, initialState);
}

to instead be:

import { useReducer } from "react";
import types from "./types";

const initialState = {
  data: [],
  error: [],
  status: types.STATUS_IDLE
};
function reducer(state, action) {
  console.log("prevState: ", state);
  console.log("action: ", action);
  switch (action.type) {
    case types.STATUS_FETCHING:
      return {
        ...state,
        status: types.STATUS_FETCHING
      };
    case types.STATUS_FETCH_SUCCESS:
      return {
        ...state,
        error: [],
        data: action.data,
        status: types.STATUS_FETCH_SUCCESS
      };
    case types.STATUS_FETCH_FAILURE:
      return {
        ...state,
        error: action.error,
        status: types.STATUS_FETCH_FAILURE
      };
    default:
      return state;
  }
}

export function useApiCallReducer() {
  return useReducer(reducer, initialState);
}

Edit useAirportsData

Here's a related answer for a variation on this problem when the reducer has dependencies (e.g. on props or other state) that require it to be defined within another function: React useReducer Hook fires twice / how to pass props to reducer?

Below is a very contrived example to demonstrate a scenario where a change in the reducer during render requires it to be re-executed. You can see in the console, that the first time you trigger the reducer via one of the buttons, it executes twice -- once with the initial reducer (addSubtractReducer) and then again with the different reducer (multiplyDivideReducer). Subsequent dispatches seem to trigger the re-render unconditionally without first executing the reducer, so only the correct reducer is executed. You can see particularly interesting behavior in the logs if you first dispatch the "nochange" action.

import React from "react";
import ReactDOM from "react-dom";

const addSubtractReducer = (state, { type }) => {
  let newState = state;
  switch (type) {
    case "increase":
      newState = state + 10;
      break;
    case "decrease":
      newState = state - 10;
      break;
    default:
      newState = state;
  }
  console.log("add/subtract", type, newState);
  return newState;
};
const multiplyDivideReducer = (state, { type }) => {
  let newState = state;
  switch (type) {
    case "increase":
      newState = state * 10;
      break;
    case "decrease":
      newState = state / 10;
      break;
    default:
      newState = state;
  }
  console.log("multiply/divide", type, newState);
  return newState;
};
function App() {
  const reducerIndexRef = React.useRef(0);
  React.useEffect(() => {
    reducerIndexRef.current += 1;
  });
  const reducer =
    reducerIndexRef.current % 2 === 0
      ? addSubtractReducer
      : multiplyDivideReducer;
  const [reducerValue, dispatch] = React.useReducer(reducer, 10);
  return (
    <div>
      Reducer Value: {reducerValue}
      <div>
        <button onClick={() => dispatch({ type: "increase" })}>Increase</button>
        <button onClick={() => dispatch({ type: "decrease" })}>Decrease</button>
        <button onClick={() => dispatch({ type: "nochange" })}>
          Dispatch With No Change
        </button>
      </div>
    </div>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

Edit different reducers

Solution 2:

Remove the <React.StrictMode> will be fixed the issues.