Importance of React Keys

Introduction

We are going to explore the importance of using keys effectively as well as going a tiny bit in depth with how react handles re-rendering.

The context

I thought I knew how React components get updated until I came across an example similar to this:

function ChatLobby() {
  const [selectedUser, setSelectedUser] = useState("Timothee");

  return (
    <div>
      <header>
        <button onClick={() => setSelectedUser("Timothee")}>
          Timothee
        </button>
        <button onClick={() => setSelectedUser("Adam")} >
          Adam
        </button>
      </header>
      
      <section>
        {selectedUser === "Timothee"
          ? <Chat with="Timothee" />
          : <Chat with="Adam" />}
      </section>
    </div>
  )
}

function Chat(props) {
  const [text, setText] = useState("");
  
  return (
    <div>
      <h4>Write a message to <span>{props.with}</span></h4>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button>Send</button>
    </div>
  )
}

I've stripped away the styling from the code above to focus on the topic at hand.

We have a chat lobby where we can select the user to whom we want to send a message. Everything seem to work fine, but we have a small issue, reproduced like this:

  1. Type a message to Timothee

  2. Don't press the send button

  3. Select Adam

The message persists even though we expected the input to clear itself.

You can try it out here and see the source code here.

Let's see what's going on

We inspect the code again and notice how Chat has an internal text state that has the initial value an empty string.

function Chat(props) {
  const [text, setText] = useState("");
  // ...
}

Additionally, ChatLobby appears to conditionally render two instances of the same Chat component.

<section>
  {selectedUser === "Timothee"
    ? <Chat with="Timothee" />
    : <Chat with="Adam" />}
</section>

Intuitively, we expect the chats with Timothee and Adam to have separate states, so switching between them should clear the input.

Can you spot the problem?

I'm ashamed to admit that I haven't spent too much time reading about it in the React docs prior to this.

React will keep the [component] state around for as long as you render the same component at the same position in the tree.

Here's the answer! After the condition gets resolved, we'll end up with the same Virtual DOM structure, so React has no reason to treat it as a new different component.

Syntactically, it appears as two different components, but semantically, you can imagine it has the same effect as this:

<section>
  <Chat with={selectedUser} />
</section>

Here, it is very easy too see why React would keep the state between renders since the only thing that is changed is the selectedUser.

One might naively try to solve the problem by manually resetting the state whenever the with prop changes, like this:

Chat.jsx
function Chat(props) {
  const [text, setText] = useState("");
  
  useEffect(() => {
    setText("");
  }, [props.with]);
  // ...
}

This will lead to a lot of confusion and unnecessary complexity down the line as the project grows larger.

Also, you might want to check out this amazingly written article in the React docs.

How to avoid the problem?

Going back to the previous example, we have multiple options to avoid this problem from happening.

Use key prop

<section>
  {selectedUser === "Timothee"
    ? <Chat key="Timothee" with="Timothee" />
    : <Chat key="Adam" with="Adam" />}
</section>

We explicitly tell React these two should be treated as new instances and we can see this happening by detecting whether Chat is mounted or not using useEffect. This did not happen with our first example, React nerver remounted Chat after the initial mount.

Try to log whenever the Chat component is being mounted and check the console while switching between Timothee and Adam.

You can read more about React mount lifecycle here.

Given that we use the same component, if we don't provide a key, React uses the component's internal index, which is equivalent to its position, hence why the component was being treated the same.

Split the condition

<section>
  {selectedUser === "Timothee" && <Chat with="Timothee" />}
  {selectedUser === "Adam" && <Chat with="Adam" />}
</section>

In this scenario, as opposed to what we had up until now, we have two ReactNodes instead of one being evaluated at a time since both conditions are resolved either into a Chat or a boolean false. It's just that we don't see the boolean node because it is being treated as an EmptyNode and does not end up in the DOM.

To clarify it further, if we select Timothee, we end up with a structure like:

{ 0: <Chat with="Timothee" />, 1: false }

and when we select Adam:

{ 0: false, 1: <Chat with="Adam" /> }

Here, React can't confuse them as being the same, since they have a different internal index, even if we don't provide a key.

We can also see this being put in action in the React's source code:

let existingChild: null | Fiber = currentFirstChild;
while (existingChild !== null) {
  if (existingChild.key !== null) {
    existingChildren.set(existingChild.key, existingChild);
  } else {
    existingChildren.set(existingChild.index, existingChild);
  }
  existingChild = existingChild.sibling;
}

where we see that React is either using the index (position) or the key to identify the component (named as existingChild).

Conclusion

I want to leave you with this simple rule of thumb: If the components conceptually should differ from one another, mark them with a key! 🎉

This topic has already been explained in much more detail in the React docs and this article serves as sort of a summary alongside my own thoughts.

Last updated