skip to Main Content

I stumbled upon an example of resetting state in the React docs: https://react.dev/learn/preserving-and-resetting-state#option-1-rendering-a-component-in-different-positions

I simplified my understanding of it into different sandboxes, and I can’t reconcile (pun not intended) what I see.

All of them feature the same implementation of Counter:

function Counter() {
  const [score, setScore] = useState(0);

  useEffect(() => {
    const i = setInterval(() => setScore((n) => n + 1), 200);
    return () => clearInterval(i);
  }, []);
  return <div>{score}</div>;
}

A Counter, once mounted, quickly counts up. If dismounted and remounted, the state is lost and reset to 0.

First, I wrap the Counter in a stateful container: Codesandbox

export default function Scoreboard() {
  const [bool, setBool] = useState(true);
  return (
    <div>
      {bool && <Counter />}
      {!bool && <Counter />}
      <button
        onClick={() => {
          setBool(!bool);
        }}
      >
        Switch
      </button>
    </div>
  );
}

When I click "Switch", it resets the state of the Counter. I refactored it into something more "Obvious" which maintains this behavior: Codesandbox

export default function Scoreboard() {
  const [bool, setBool] = useState(false);
  return (
    <div>
      {bool ? (
        <>
          <div />
          <Counter />
        </>
      ) : (
        <>
          <Counter />
          <div />
        </>
      )}
      <button
        onClick={() => {
          setBool(!bool);
        }}
      >
        Switch
      </button>
    </div>
  );
}

My takeaway here is that regardless of the "Type" of component, if the position changes (from first to second), then the state is not carried over. So I wondered why I never encountered this before, given that it’s common to write components which have dynamic maps followed by stateful elements.

I wrote an example to test it: Codesandbox

export default function Scoreboard() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <>
        {Array(count)
          .fill(0)
          .map(() => (
            <div>a</div>
          ))}
        <Counter />
      </>
      <button
        onClick={() => {
          setCount((n) => n + 1);
        }}
      >
        Switch
      </button>
    </div>
  );
}

Now very surprisingly, despite the position of Counter changing on every click to Switch, the state is never reset.

Why is this happening? Why is state not preserved in the first two examples, but is in the third.

In all cases, the only thing changing from the perspective of React is the position of the Counter relative to its siblings.

Footnote: In all cases, the key is unspecified and defaults to the index. Reminder: calling an element in JSX in separate positions does NOT give them separate identities. Otherwise the state would not be reset here: https://codesandbox.io/s/counterreset-1-identity-7rxrc2?file=/App.js:127-158

Additionally, I know that giving Counter a key would fix this. I don’t want to "fix" it, I want to understand why the behavior differs.

2

Answers


  1. React re-renders by comparing DOMs. In first and second example, React consider your <Counter/> as 2 different components, since, well, you defined 2 Counter separately:

    • The initial render DOM will be:
    <div>
      <Counter/>
      {null}
      <button/>
    </div>
    
    • And the DOM after clicking the button will be:
    <div>
      {null}
      <Counter/>
      <button/>
    </div>
    

    React will look for the state of bool since it’s the one that changed, and it will realize that it have to remove the old first <Counter/>. After that it move on to the next node and realize that it have to add a new <Counter/>. It doesn’t see the code like us human seeing both counters is the same, it executes and see those 2 as 2 different nodes.

    In the last example, React will see the array has been changed, and try to replace it. After replacing the array, it will check the Counter node and realize that nothing happens with it, so it won’t re-render the Counter.

    Reference: https://react.dev/learn/render-and-commit – here it said that React calls our components recursively so I used it as the base of my thought process.

    Edit: My old answer is misleading with the key things. This update is elaborated on how React comparing DOMs in each examples and remove the misleading part

    Login or Signup to reply.
  2. {Array(count)
      .fill(0)
      .map(() => (
        <div>a</div>
      ))}
    <Counter />
    

    I think what you’re missing is that this will always create exactly 2 nodes at the top level: an array, and a counter. The contents inside the array will grow/shrink/change, but react starts by comparing just the top level types, and only later recurses into the array.

    On the first render, element 0 was an array, and element 1 was a Counter. On the second render, element 0 is still an array, and element 1 is still a Counter. Element 0’s type has not changed, and neither has element 1’s type, so nothing gets mounted/unmounted (at this point). Notably, the Counter does not mount/unmount. React will then recursively look inside the array and decide if it needs to mount/unmount anything in there, but this does not retroactively affect the Counter.

    To show that it’s the array that’s causing this behavior, let me rewrite the code so i’m directly calling React.createElement, which is what JSX tags get transpiled to. The following code is essentially identical to your 3rd code:

    import { Fragment, createElement } from "react";
    
    export default function Scoreboard() {
      const [count, setCount] = useState(0);
    
      const divs = Array(count)
        .fill(0)
        .map(() => <div>a</div>);
      const fragment = createElement(Fragment, null, divs, <Counter/>)
      return (
        <div>
          {fragment}
          <button
            onClick={() => {
              setCount((n) => n + 1);
            }}
          >
            Switch
          </button>
        </div>
      );
    }
    

    The createElement line is saying to make a Fragment (ie, <>). The null says it should have no props (other than children). Then the rest of the arguments are the children, in this case an array, followed by a Counter. Ie, this matches what your code is doing. And indeed it has the same behavior: the counter does not reset.

    But now we can make one small change to the code:

    const fragment = createElement(Fragment, null, ...divs, <Counter/>)
    

    I am now spreading ...divs, meaning the individual divs are passed in as separate arguments. So instead of having 2 children, it now has a variable number of children. If you run this code, then the counter does reset. This is because whatever the last child was on the previous render, it was a <Counter/>. And now, the type at that same index is a <div>. Thus, the counter must unmount and a div must mount. And at the new last index there was formerly no element, and now there’s a Counter. This is a new type, so it mounts.

    Sandbox Link

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search