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:

    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:

    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