Does strict mode work differently with React 18?

TL;DR

When components are wrapped in StrictMode, React runs certain functions twice in order to help developers catch mistakes in their code.

And this happens both in React 18 and React 17 but the reason you aren’t experiencing this with the latter is because in React 17, React automatically silences logs in the second call.

If you extract out console.log and use the extracted alias to log, then you would get similar behavior with both versions.

const log = console.log;

function App() {
  const [count, setCount] = React.useState(0);
  log(count);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById("root")
);
<script crossorigin src="https://unpkg.com/react@17/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
<div id="root"></div>

Note:

In React 17, React automatically modifies the console methods like console.log() to silence the logs in the second call to lifecycle functions. However, it may cause undesired behavior in certain cases where a workaround can be used.

Starting from React 18, React does not suppress any logs. However, if you have React DevTools installed, the logs from the second call will appear slightly dimmed. React DevTools also offers a setting (off by default) to suppress them completely.

Source

Now let’s dive deep to understand what actually happens in strict mode and how it can helpful.


Strict Mode

Strict Mode is a tool that helps identify coding patterns that may cause problems when working with React, like impure renders.

In Strict Mode in development, React runs the following functions twice:

  • Functional Components
  • Initializers
  • Updaters

And this is because your components, initializers & updaters need to be pure functions but if they aren’t then double-invoking them might help surface this mistake. And if they are pure, then the logic in your code is not affected in any manner.

Note: React uses the result of only one of the calls, and ignores the result of the other.

In the example below observe that components, initializers & updaters all run twice during development when wrapped in StrictMode (snippet uses the development build of React).

// Extracting console.log in a variable because we're using React 17
const log = console.log; 

function App() {
  const [count, setCount] = React.useState(() => {
    log("Initializers run twice");
    return 0;
  });

  log("Components run twice");

  const handleClick = () => {
    log("Event handlers don’t need to be pure, so they run only once");
    setCount((count) => {
      log("Updaters run twice");
      return count + 1;
    });
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Increment</button>
    </div>
  );
}

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById("root")
);
<script crossorigin src="https://unpkg.com/react@17/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
<div id="root"></div>

Few notes from the above example:

  • You might have noticed that when you click the button for the first time the Updaters run twice log prints only once but on subsequent clicks it prints twice. But you can ignore this behavior and assume that it always prints twice but if you want more details about the same you can follow this github issue.

  • We had to extract console.log into a separate variable to get logs for both the invocations printed and this is because React 17 automatically silences logs for the second call (as mentioned in the TL;DR). If you update the CDN link to React 18, then this extraction wouldn’t be required.

  • Calling the setCount updater function twice doesn’t mean that it would now increment the count twice on every click, no, because it calls the updater with the same state both the times. So, as long as your updaters are pure functions, your application wouldn’t get affected by the no. of times it’s called.

  • “Updaters” & “Initializers” are generic terms in React. State updaters & state initializers are just one amongst many. Other updaters are “callbacks” passed to useMemo and “reducers”. Another initializers is useReducer initializer etc. And all of these should be pure functions so strict mode double invokes all of them. Checkout this example:

const logger = console.log;

const countReducer = (count, incrementor) => {
  logger("Updaters [reducers] run twice");
  return count + incrementor;
};

function App() {
  const [count, incrementCount] = React.useReducer(
    countReducer,
    0,
    (initCount) => {
      logger("Initializers run twice");
      return initCount;
    }
  );

  const doubleCount = React.useMemo(() => {
    logger("Updaters [useMemo callbacks] run twice");
    return count * 2;
  }, [count]);

  return (
    <div>
      <p>Double count: {doubleCount}</p>
      <button onClick={() => incrementCount(1)}>Increment</button>
    </div>
  );
}

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);
<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>
<div id="root"></div>

How is Strict Mode helpful?

Let’s look at an example where Strict Mode would help us find a serious mistake.

// This example is in React 18 to highlight the fact that 
// the double invocation behavior is similar in both React 17 & 18.

function App() {
  const [todos, setTodos] = React.useState([
    { id: 1, text: "Learn JavaScript", isComplete: true },
    { id: 2, text: "Learn React", isComplete: false }
  ]);

  const handleTodoCompletion = (todoId) => {
    setTodos((todos) => {
      console.log(JSON.stringify(todos));
      return todos.map((todo) => {
        if (todo.id === todoId) {
          todo.isComplete = !todo.isComplete; // Mutation here
        }
        return todo;
      });
    });
  };

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>
          <span
            style={{
              textDecoration: todo.isComplete ? "line-through" : "none"
            }}
          >
            {todo.text}
          </span>
          <button onClick={() => handleTodoCompletion(todo.id)}>
            Mark {todo.isComplete ? "Incomplete" : "Complete"}
          </button>
        </li>
      ))}
    </ul>
  );
}

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<App />);
<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>
<div id="root"></div>

What’s the problem with the above example?

You would’ve noticed that the buttons don’t work as expected, they don’t toggle the isComplete boolean and the problem is that the updater function passed to setTodos is not a pure function as it mutates an object in the todos state. And since the updater is called twice, and it is not a pure function, the second call reverses the isComplete boolean back to it’s original value.

Note: It’s only because of strict mode’s double invocation that we were able to catch this mistake. If we opt out of strict mode, then the component would luckily work as expected but that doesn’t mean the code is authored correctly, it only works because of how isolated the component is and in real world scenarios mutations like these can cause serious issues. And even if you luckily get away with such mutations, you might still encounter problems because currently the updater relies on the fact that it’s only called once for every click but this is not something that React guarantees (with concurrency features in mind).

If you make the updater a pure function, it would solve the issue:

setTodos((todos) => {
  logger(JSON.stringify(todos, null, 2));
  return todos.map((todo) =>
    todo.id === todoId ? { ...todo, isComplete: !todo.isComplete } : todo
  );
});

What’s new with Strict Mode in React 18

In React 18, StrictMode gets an additional behavior to ensure it’s compatible with reusable state. When Strict Mode is enabled, React intentionally double-invokes effects (mount -> unmount -> mount) for newly mounted components. This is to ensure that a component is resilient to being “mounted” and “unmounted” more than once. Like other strict mode behaviors, React only does this for development builds.

Consider the example below (Source):

function App(props) {
  React.useEffect(() => {
    console.log("Effect setup code runs");

    return () => {
      console.log("Effect cleanup code runs");
    };
  }, []);

  React.useLayoutEffect(() => {
    console.log("Layout effect setup code runs");

    return () => {
      console.log("Layout effect cleanup code runs");
    };
  }, []);
  
  console.log("React renders the component")
  
  return <h1>Strict Effects In React 18</h1>;
}

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);
<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>
<div id="root"></div>

The App component above declares some effects to be run on mount and unmount. Prior to React 18, the setup functions would only run once (after the component is initially mounted) and the cleanup functions would also run only once (after the component is unmounted). But in React 18 in StrictMode, the following would happen:

  • React renders the component (twice, nothing new)
  • React mounts the component
    • Layout effect setup code runs
    • Effect setup code runs
  • React simulates the component being hidden or unmounted
    • Layout effect cleanup code runs
    • Effect cleanup code runs
  • React simulates the component being shown again or remounted
    • Layout effect setup code runs
    • Effect setup code runs

Suggested Readings

Leave a Comment