Component rerenders when state has not changed on the second click

Viewed 179

I have a tabs component that changes state every time a different tab is clicked.

Component Parent

import { useState } from "react";
import "./styles.scss";
import MemoizedTab from "./tab";

export default function App() {
  const [selectTab, setSelectTab] = useState("a");
  console.log("parent render");
  return (
    <div className="App">
      <div className="tab-list">
        <MemoizedTab
          tab={"a"}
          title={"First Title"}
          setSelectTab={setSelectTab}
        />
        <MemoizedTab
          tab={"b"}
          title={"Second Title"}
          setSelectTab={setSelectTab}
        />
        <MemoizedTab
          tab={"c"}
          title={"Third Title"}
          setSelectTab={setSelectTab}
        />
      </div>
      {selectTab === "a" && <div>this is a</div>}
      {selectTab === "b" && <div>this is b</div>}
      {selectTab === "c" && <div>this is c</div>}
    </div>
  );
}

Component Child

import { memo } from "react";

const MemoizedTab = memo(({ title, tab, setSelectTab }) => {
  console.log("child render");
  const handleClick = (tab) => {
    setSelectTab(tab);
  };
  return <p onClick={() => handleClick(tab)}>{title}</p>;
});

export default MemoizedTab;

After the initial render the parent has a state of "a". When I click on "First Title" which sets the state to "a" again, nothing renders as expected.

When I first click on "Second Title" which sets the state to "b", I get a console log of "parent renders", which is as expected. But when I click on "Second Title" for the second time, I get a console log of "parent renders" again even though the state didn't change. Nothing logs on the third or fourth clicks. It's always the second.

This same behavior happens when I click on "Third Title" which sets the state to "c".

Why does my parent re-render on the second click after state transitions?

Codesandbox Link

2 Answers

React is not actually re-rendering your component. You can verify this by moving the console.log inside the componentDidUpdate hook.

useEffect(()=>{
   console.log('Parent re-rendered!')
})

This console.log won't get logged by the second time you click on any of the tabs.

While the console.log in your provided example does get printed, React eventually bails out of the state update. This is actually an expected behaviour in React. The following extract is from the docs:

If you update a State Hook to the same value as the current state, React will bail out without rendering the children or firing effects. (React uses the Object.is comparison algorithm.)

Note that React may still need to render that specific component again before bailing out. That shouldn’t be a concern because React won’t unnecessarily go “deeper” into the tree.

This is an interesting one - I did some digging and found the following related React issues:

  1. Hooks: Calling setState with the SAME value multiple times, evaluates the component function up-to 2 times

    • This is the same issue that you are describing, from a comment there by Sebastian Markbåge it appears React requires the additional render to confirm that the two versions are the same:

      This is a known quirk due to the implementation details of concurrency in React. We don't cheaply know which of two versions is currently committed. When this ambiguity happens we have to over render once and after that we know that both versions are the same and it doesn't matter.

  2. useState not bailing out when state does not change

    • This describes the same underlying concept - setting state to the same value may result in the functional component to running again. The key takeaway from them is the last comment from Dan Abramov on the second issue:

      React does not offer a strong guarantee about when it invokes render functions. Render functions are assumed to be pure and there should be absolutely no difference for correctness regardless of how many times they're called. If calling it an extra time causes a bug, it's a problem with the code that needs to be fixed.

      From the performance point of view, the cost of re-running a single function is usually negligible. If it's not, you should add some useMemos to the parts that are expensive. React does guarantee that if a bailout happens, it will not go updating child components. Even though it might in some cases re-run the component function itself.

So React doesn't gaurntee when it will invoke the functional component, and in this case it appears to run it the additional time checking that the result is the same.

The good news is that although it runs the function an additional time, from what I can tell it doesn't appear to actually rerender on the second invocation - if you use the React profiler, looking at the Flamegraph you can see the first time App renders due to the hook change: enter image description here

but on the second click, App does not actually rerender: enter image description here

Related