1

I'm using React's Strict mode, and I'm having a useEffect that is causing me some headaches. Part of React's Strict mode is that those things can fire twice, so I'm writing code to protect myself from that.

However I seem to be missing something when using useReducer. By using useState I'm able to update the state so that useEffect becomes idempotent.

With useReducer I dispatch an action which only seems to be executed AFTER useEffect has rendered twice, effectively nullifying my idempotency.

useState example (pardon the React setup cruft, StackOverflow doesn't support React 18 natively):

const { StrictMode, useState, useEffect } = React;

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <StrictMode>
    <WithUseState />
  </StrictMode>
);

function WithUseState() {
  const [counter, setCounter] = useState(0);

  useEffect(() => {
    if (counter === 0) {
      setCounter(counter + 1);
    }
  }, [counter]);

  return <React.Fragment>{counter}</React.Fragment>;
}
<div id="root"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.development.js"></script>

useReducer example, where the order seems to be: useEffect, useEffect, resolve dispatch, resolve dispatch:

const { StrictMode, useEffect, useReducer } = React;

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <StrictMode>
    <WithUseReducer />
  </StrictMode>
);

function reducer(state, action) {
  switch (action.type) {
case "increment":
  return { ...state, counter: state.counter + 1 };
default:
  throw new Error();
  }
}

function WithUseReducer() {
  const [state, dispatch] = useReducer(reducer, { counter: 0 });

  useEffect(() => {
if (state.counter === 0) {
  dispatch({ type: "increment" });
}
  });

  return <React.Fragment>{state.counter}</React.Fragment>;
}
<div id="root"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.development.js"></script>
ggorlen
  • 44,755
  • 7
  • 76
  • 106
Anemoia
  • 7,928
  • 7
  • 46
  • 71
  • This doesn't happen with the production build of react, i.e. the reducer will not run twice. – morganney Oct 08 '22 at 22:13
  • Check out this blog-post where they bring up the third parameter for `useReducer` (init function). https://blog.logrocket.com/react-usereducer-hook-ultimate-guide/ should allow you to get the same result in dev and production environments. – Joel Oct 08 '22 at 22:27

3 Answers3

0

You can use the production build of React. useEffect running twice was added in React 18: https://reactjs.org/docs/strict-mode.html#ensuring-reusable-state

const { StrictMode, useEffect, useReducer } = React;

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <StrictMode>
    <WithUseReducer />
  </StrictMode>
);

function reducer(state, action) {
  switch (action.type) {
case "increment":
  return { ...state, counter: state.counter + 1 };
default:
  throw new Error();
  }
}

function WithUseReducer() {
  const [state, dispatch] = useReducer(reducer, { counter: 0 });

  useEffect(() => {
if (state.counter === 0) {
  dispatch({ type: "increment" });
}
  });

  return <React.Fragment>{state.counter}</React.Fragment>;
}
<div id="root"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
morganney
  • 6,566
  • 1
  • 24
  • 35
0

In react 18, useEffect will be called twice in Strict Mode This happens only in development mode not in production mode . to prevent this behavior in development mode you can use a custom hook instead of useEffect .

export const useEffectOnce = (effect) => {
  const destroyFunc = useRef();
  const effectCalled = useRef(false);
  const renderAfterCalled = useRef(false);
  const [val, setVal] = useState(0);

  if (effectCalled.current) {
    renderAfterCalled.current = true;
  }

  useEffect(() => {
    // only execute the effect first time around
    if (!effectCalled.current) {
      destroyFunc.current = effect();
      effectCalled.current = true;
    }

    // this forces one render after the effect is run
    setVal((val) => val + 1);

    return () => {
      // if the comp didn't render since the useEffect was called,
      // we know it's the dummy React cycle
      if (!renderAfterCalled.current) {
        return;
      }
      if (destroyFunc.current) {
        destroyFunc.current();
      }
    };
  }, []);
};

check this codesandbox (it is using you example but firing just once )

monim
  • 3,641
  • 2
  • 10
  • 25
0

You could add a cleanup function to your useEffect that resets the state when unmounted (because React 18 mounts, then unmounts, then mounts again).

(The isMounted state is only needed if state should be persistent between views/different component renders).

const { StrictMode, useEffect, useReducer } = React;

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <StrictMode>
    <WithUseReducer />
  </StrictMode>
);

function reducer(state, action) {
  switch (action.type) {
    case "increment":
      return { ...state, counter: state.counter + 1 };
    case "mount-reset":
      if (!state.isMounted)
        return { ...state, counter: 0, isMounted: true };
    default:
      throw new Error();
  }
}

function WithUseReducer() {
  const [state, dispatch] = useReducer(reducer, { counter: 0 });

  useEffect(() => {
    if (state.counter === 0) {
      dispatch({ type: "increment" });
    }
    return () => dispatch({ type: "mount-reset" });
  }, []);

  return <React.Fragment>{state.counter}</React.Fragment>;
}
<div id="root"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.development.js"></script>

See these for further explanations:

Bug: useEffect runs twice on component mount (StrictMode, NODE_ENV=development)

How to handle data fetching happening twice?

Bqardi
  • 107
  • 1
  • 5