1

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...

0x24a537r9
  • 968
  • 1
  • 10
  • 24

1 Answers1

2

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.

ray
  • 26,557
  • 5
  • 28
  • 27
  • That's it, and I was just now able to verify this with by preventing React from overwriting `console.info` by grabbing an early reference to it with: https://codesandbox.io/s/xenodochial-mendeleev-2lh6q?file=/src/App.js:0-849 Lower down that page the docs do say: "Note: Starting with 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." – 0x24a537r9 Oct 20 '21 at 01:47
  • Ah. Gotcha. I figured they were swallowing it but missed that bit in the docs. Cheers. – ray Oct 20 '21 at 01:56