Search code examples
javascriptreactjsreact-hooksreact-strictmode

How to fix React calling `setState` twice in `useEffect` that has an empty dependency array in strict mode?


const { createRoot } = ReactDOM;
const { StrictMode, useEffect, useState } = React;

function Test() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setCount((prevCount) => prevCount + 1);
  }, []);

  return (
    <h1>Count: {count}</h1>
  );
}

const root = createRoot(document.getElementById("root"));
root.render(<StrictMode><Test /></StrictMode>);
body {
  font-family: sans-serif;
}
<div id="root"></div>
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>

Consider the above snippet proposing a hypothetical situation. The Test component has a useEffect which increments the count by 1. The useEffect has an empty dependency array which means it should only get called on mount. Therefore, the count should be 1. However, the count is 2, when the strict mode is enabled.

This question is drawn from a comment thread that started here.


Solution

  • What is strict mode doing?

    In React, strict mode will run effects twice. From React's documentation (emphasis mine):

    Strict Mode enables the following development-only behaviors:

    Cleaning up state?

    The documentation says it's to find bugs caused by missing effect cleanup so you might be thinking the following. What is there to clean up in this effect? The effect isn't controlling a non-React widget (example Stack Overflow question) nor subscribing to an event (example Stack Overflow question); it's just updating some state, there's nothing to clean up.

    However, there is clean up that can be done. The clean up is undoing the operation performed in the effect. The useEffect would look like this:

    useEffect(() => {
      setCount((prevCount) => prevCount + 1);
      
      return () => setCount(prevCount => prevCount - 1);
    }, []);
    

    The full working example:

    const { createRoot } = ReactDOM;
    const { StrictMode, useEffect, useState } = React;
    
    function Test() {
      const [count, setCount] = useState(0);
    
      useEffect(() => {
        setCount((prevCount) => prevCount + 1);
        
         return () => setCount(prevCount => prevCount - 1);
      }, []);
    
      return (
        <h1>Count: {count}</h1>
      );
    }
    
    const root = createRoot(document.getElementById("root"));
    root.render(<StrictMode><Test /></StrictMode>);
    body {
      font-family: sans-serif;
    }
    <div id="root"></div>
    <script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
    <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>

    If you're thinking this is strange, I'd say:

    • This is a hypothetical example. I can't think of an actual use case for setting state on mount (without doing anything else before, which may require a different way of cleaning up). If you do encounter something like this in a non-hypothetical situation, there may be smells of "you might not need an effect". Although, I haven't thought super hard so let me know if there's an actual use case you've thought of (it may also be worth it's own question).
    • React's documentation supports this (emphasis mine):

      Usually, the answer is to implement the cleanup function. The cleanup function should stop or undo whatever the Effect was doing.

    • There's also a similar example regarding state outside of React. See the example for handling useEffect firing twice when triggering animations. Code excerpt below:
      useEffect(() => {
        const node = ref.current;
        node.style.opacity = 1; // Trigger the animation
        return () => {
          node.style.opacity = 0; // Reset to the initial value
        };
      }, []);
      
    • You need to understand why cleaning up, even state, is important. Onto this next.

    When we think of React unmounting a component it's easy to think React just throws away the component and everything that comes with it (like state). However, this is not true. React might unmount a component then remount it with restored state. This might happen in cases like:

    • Development fast refresh: making changes to components without losing state. You change a component and save a file, React unmounts the old component and remounts the new one and restores state from before the unmount.
    • Offscreen components (upcoming API as part of React's concurrent features): a component is unmounted when it goes off the screen then remounted without losing state. This is useful in cases like:
      • Fluid navigation between screens (or tabs) in an app. You'd want to restore state when navigating back (or prerender when navigating forward).
      • Making long lists more performant. Items off the screen may be unmounted (or also prerendered) and remounted when they come back on.

    If cleanup isn't performed you'll encounter bugs like the one in the question.

    A practical test with fast refresh

    You can reproduce this yourself relatively easily by doing the following:

    1. Start a new Create React App project. This has fast refresh enabled by default.
    2. Setup the relevant files with the code in the question.
    3. Start the app.
    4. Make a change to the Test component and save it.

    When you view the app, you'll see the component has updated with the change in step 4 however the count has also incremented. You can make multiple changes, saving each time and see the count continue to increment. This is because React unmounted the component then remounted it without losing state. The setState in the useEffect then operates on this restored state.

    Now add in the cleanup described earlier and hard refresh the app to restore its initial state. You can perform step 4 again and see the count doesn't increment on every change.

    Further reading