Home > Enterprise >  Can React's useState setter lambdas run multiple times?
Can React's useState setter lambdas run multiple times?

Time:10-20

To be honest, I'm struggling to come up with a way to phrase this question besides "What is happening here?" Take the following React code designed to add incremental items to a list:

import React, { useState } from "react";
import "./styles.css";

let counter = 0;

export default function App() {
  const [list, setList] = useState([]);

  console.info("Render:", counter, list.join());

  return (
    <div className="App">
      {list.join()}
      <button
        onClick={() => {
          setList((prevList) => {
            console.info("Pre-push:", counter, prevList.join());
            const newList = [...prevList, "X"     counter];
            console.info("Post-push:", counter, newList.join());
            return newList;
          });
        }}
      >
        Push
      </button>
    </div>
  );
}

If you run that code with https://codesandbox.io/s/amazing-sea-6ww68?file=/src/App.js and click the "Push" button four times, I would expect to see "X1" then "X1,X2" then "X1,X2,X3", then "X1,X2,X3,X4". Simple enough right? Instead, it renders "X1" then "X1,X3" then "X1,X3,X5" then "X1,X3,X5,X7".

Now I thought, "huh, perhaps the function that increments counter is being called twice?", so I added the console logging you see, which only mystified me more. In the console, I see:

Render: 0 "" 
Pre-push: 0 "" 
Post-push: 1 X1 
Render: 1 X1 
Pre-push: 1 X1 
Post-push: 2 X1,X2 
Render: 2 X1,X2 
Pre-push: 3 X1,X3 
Post-push: 4 X1,X3,X4 
Render: 4 X1,X3,X4 
Pre-push: 5 X1,X3,X5 
Post-push: 6 X1,X3,X5,X6 
Render: 6 X1,X3,X5,X6 

Note that the joined list in the console doesn't match the joined list rendered by React, there is no record of how counter gets bumped from 2 -> 3 and 4 -> 5, and the third item of the list mysteriously changes, despite the fact that I only ever append to the list.

Notably, if I move the counter out of the setList delegate, it works as expected:

import React, { useState } from "react";
import "./styles.css";

let counter = 0;

export default function App() {
  const [list, setList] = useState([]);

  console.info("Render:", counter, list.join());

  return (
    <div className="App">
      {list.join()}
      <button
        onClick={() => {
            counter;
          setList((prevList) => {
            console.info("Pre-push:", counter, prevList.join());
            const newList = [...prevList, "X"   counter];
            console.info("Post-push:", counter, newList.join());
            return newList;
          });
        }}
      >
        Push
      </button>
    </div>
  );
}

What on earth is going on here? I suspect this is related to the internal implementation of React fibers and useState, but I'm still at a total lost to how counter could be incremented without the console logs right before it and after it showing evidence of such, unless React is actually overwriting console so that it can selectively suppress logs, which seems like madness...

CodePudding user response:

It seems like it's getting invoked twice because it is.

When running in strict mode, React intentionally invokes the following methods twice when running in development mode:

Strict mode can’t automatically detect side effects for you, but it can help you spot them by making them a little more deterministic. This is done by intentionally double-invoking the following functions:
  • Class component constructor, render, and shouldComponentUpdate methods
  • Class component static getDerivedStateFromProps method
  • Function component bodies
  • State updater functions (the first argument to setState)
  • Functions passed to useState, useMemo, or useReducer

Not sure what happens to the console.log calls, but I bet this problem goes away if you switch to production mode.

  • Related