Keys in React. Cooking right

  • Tutorial

Today let's talk about the attribute key in React. Often, developers who are just starting to use React do not attach much importance to the attribute key . But in vain ...


image
What the duck says when she found out that you are not using key


To present the keys in full and with different cases, consider the plan:


  1. Reconciliation
  2. Key Reuse and Normalization
  3. Using key when rendering a single item
  4. Working with keys when passing to children

Since there is a lot of material, there will be conclusions at the end of each part. At the end of the article, a general conclusion is also given and theses are briefly described. The code can be viewed both on examples in codesandbox, and under spoilers.




Reconciliation


The main task of the keys in the reaction is to help the reconciliation mechanism. Let's create a small component that will render a list of names:


import React from "react";
import { render } from "react-dom";

class App extends React.Component {
  state = {
    names: ["Миша", "Даниил", "Марина"]
  };
  render() {
    return <Names names={this.state.names} />;
  }
}

class Names extends React.PureComponent {
  render() {
    return (<ul>{this.props.names.map(name => <Name>{name}</Name>)}</ul>);
  }
}

class Name extends React.PureComponent {
  render() {
    return (<li>{this.props.children}</li>);
  }
}

render(<App />, document.getElementById("root"));

We did not specify any key. In the console we will see the message:


Warning: Each child in an array or iterator should have a unique "key" prop.

Now we complicate the task and create an input with a button to add a new name to the beginning and the end. In addition, DidMount we will add change logging to componentDidUpdate and DidMount for the Name component, indicating children:


Adding items to the list
import React, { Component, PureComponent, Fragment } from "react";
import { render } from "react-dom";

class App extends Component {
  state = {
    names: ["Миша", "Даниил", "Марина"]
  };
  addTop = name => {
    this.setState(state => ({
      names: [name, ...state.names]
    }));
  };
  addBottom = name => {
    this.setState(state => ({
      names: [...state.names, name]
    }));
  };
  render() {
    return (
      <Fragment>
        <Names names={this.state.names} />
        <AddName addTop={this.addTop} addBottom={this.addBottom} />
      </Fragment>
    );
  }
}

class AddName extends PureComponent {
  getInput = el => {
    this.input = el;
  };
  addToTop = () => {
    if (!this.input.value.trim()) {
      return;
    }
    this.props.addTop(this.input.value);
    this.input.value = "";
  };
  addToBottom = () => {
    if (!this.input.value.trim()) {
      return;
    }
    this.props.addBottom(this.input.value);
    this.input.value = "";
  };
  render() {
    return (
      <Fragment>
        <input ref={this.getInput} />
        <button onClick={this.addToTop}>Add to TOP</button>
        <button onClick={this.addToBottom}>Add to BOTTOM</button>
      </Fragment>
    );
  }
}

class Names extends PureComponent {
  render() {
    return <ul>{this.props.names.map(name => <Name>{name}</Name>)}</ul>;
  }
}

class Name extends PureComponent {
  componentDidMount() {
    console.log(`Mounted with ${this.props.children}`);
  }
  componentDidUpdate(prevProps) {
    console.log(`Updated from ${prevProps.children} to ${this.props.children}`);
  }
  render() {
    return <li>{this.props.children}</li>;
  }
}

render(<App />, document.getElementById("root"));


Try adding “Basil” to the end of the list, and then “Paul” to the beginning. Pay attention to the console. Codesandbox also allows you to open the source code by clicking on the buttons to change the display (top to center).


Demonstration of the work of a similar list:



When adding an element from above, we get a situation when the Name components are redrawn and a new component is created with children === Василий :


Updated from Миша to Павел
Updated from Даниил to Миша
Updated from Марина to Даниил
Updated from Василий to Марина
Mounted with Василий

Why is this happening? Let's take a look at the reconciliation mechanism.




Complete reconciliation and reduction of one tree to another is an expensive task with algorithmic complexity O (n³). This means that with large amounts of elements, the reaction would be slow.
For this reason, the VDOM reconciliation mechanism works using the following simplifications (rules):


1) Two elements of different types will produce different subtrees - which means that when changing the type of an element from <div> to <section> or another tag, the react considers the subtrees inside <div> and <section> different. The reaction removes the elements that were inside div , and mounts all the elements inside the section. Even if only the tag itself has changed. A similar situation of tree deletion-initialization occurs when one react component is changed to another, although the content itself, it would seem, remains the same (but this is only a misconception).


oldTree:
<div>
  <MyComponent />
</div>

// MyComponent будет удален и создан заново

newTree:
<section>
  <MyComponent />
</section>

It works similarly with React components:


// В этом примере будет выведено:
// did mount
// will unmount
// did mount

// То есть при смене родителя, MyComponent вначале будет удален,
// а затем создан новый инстанс MyComponent. 
class MyComponent extends PureComponent {
  componentDidMount() {
    console.log("did mount");
  }
  componentDidUpdate() {
    console.log("did update");
  }
  componentWillUnmount() {
    console.log("will unmount");
  }
  render() {
    return <div>123</div>;
  }
}

class A extends Component {
  render() {
    return <MyComponent />;
  }
}

class B extends Component {
  render() {
    return <MyComponent />;
  }
}

class App extends Component {
  state = {
    test: A
  };
  componentDidMount() {
    this.setState({test: B});
  }
  render() {
    var Component = this.state.test;
    return (
        <Component />
    );
  }
}

render(<App />, document.getElementById("root"));

2) Arrays of elements are compared element by element, i.e., the react is simultaneously iterated over two arrays and compares elements in pairs. Therefore, we got a redraw of all the elements in the list in the example with the names above. Let's look at an example:


// oldTree
<ul>
  <li>Паша</li>
  <li>Саша</li>
</ul>

// newTree
<ul>
  <li>Паша</li>
  <li>Саша</li>
  <li>Гриша</li>
</ul>

The reaction will first compare <li>Паша</li> with each other, then <li>Саша</li> at the end it will find that it is <li>Гриша</li> not in the old tree. And will create this element.


In the case when we add the element up:


// oldTree
<ul>
  <li>Паша</li>
  <li>Саша</li>
</ul>

// newTree
<ul>
  <li>Гриша</li>
  <li>Паша</li>
  <li>Саша</li>
</ul>

React compares <li>Паша</li> with <li>Гриша</li> - updates it. Then compare <li>Саша</li> with <li>Паша</li> - update it and create it at the end <li>Саша</li> . When an element is inserted at the beginning, the react will update all elements in the array.




To solve the problem, the key attributes are used in the reaction. When adding key, the react will not compare the elements one after another, but will search by the value of the key. An example with rendering names will become more productive:


// oldTree
<ul>
  <li key='1'>Паша</li>
  <li key='2'>Саша</li>
</ul>

// newTree
<ul>
  <li key='3'>Гриша</li>
  <li key='1'>Паша</li>
  <li key='2'>Саша</li>
</ul>

REACT finds key='1' , key='2' determines that c them there were no changes, and then find the new item <li key='3'>Гриша</li> and add it only. Consequently, with keys, the react will update only one component.


We rewrite our example by adding the keys. Note that now when adding elements to the top of the list, only one component is created:



I note that we added id to our names and manage keys directly, without using the index of the element in the array as key. This is because when you add a name to the top of the list, the indexes will go.


To summarize the first part:


Keys optimize the work with elements of arrays, reduce the number of unnecessary deletions and creation of elements.




Key Reuse and Normalization


Let's complicate the task. Now create a list of not abstract people, but a list of people - members of the development team. The company has two teams. A team member can be selected by clicking the mouse. Let's try to solve the problem "on the forehead." Try to highlight people and switch between teams:


Side effects when duplicating keys
import React, { Component, PureComponent, Fragment } from "react";
import { render } from "react-dom";
import "./style.css";

class App extends Component {
  state = {
    active: 1,
    teams: [
      {
        id: 1,
        name: "Amazing Team",
        developers: [
          { id: 1, name: "Миша" },
          { id: 2, name: "Екатерина" },
          { id: 3, name: "Валерий" }
        ]
      },
      {
        id: 2,
        name: "Another Team",
        developers: [
          { id: 1, name: "Саша" },
          { id: 2, name: "Даниил" },
          { id: 3, name: "Марина" }
        ]
      }
    ]
  };
  addTop = name => {
    this.setState(state => ({
      teams: state.teams.map(
        team =>
          team.id === state.active
            ? {
                ...team,
                developers: [
                  { id: team.developers.length + 1, name },
                  ...team.developers
                ]
              }
            : team
      )
    }));
  };
  addBottom = name => {
    this.setState(state => ({
      teams: state.teams.map(
        team =>
          team.id === state.active
            ? {
                ...team,
                developers: [
                  ...team.developers,
                  { id: team.developers.length + 1, name }
                ]
              }
            : team
      )
    }));
  };
  toggle = id => {
    this.setState(state => ({
      teams: state.teams.map(
        team =>
          team.id === state.active
            ? {
                ...team,
                developers: team.developers.map(
                  developer =>
                    developer.id === id
                      ? { ...developer, highlighted: !developer.highlighted }
                      : developer
                )
              }
            : team
      )
    }));
  };
  switchTeam = id => {
    this.setState({ active: id });
  };
  render() {
    return (
      <Fragment>
        <TeamsSwitcher onSwitch={this.switchTeam} teams={this.state.teams} />
        <Users
          onClick={this.toggle}
          names={
            this.state.teams.find(team => team.id === this.state.active)
              .developers
          }
        />
        <AddName addTop={this.addTop} addBottom={this.addBottom} />
      </Fragment>
    );
  }
}

class TeamsSwitcher extends PureComponent {
  render() {
    return (
      <ul>
        {this.props.teams.map(team => (
          <li
            onClick={() => {
              this.props.onSwitch(team.id);
            }}
            key={team.id}
          >
            {team.name}
          </li>
        ))}
      </ul>
    );
  }
}

class AddName extends PureComponent {
  getInput = el => {
    this.input = el;
  };
  addToTop = () => {
    if (!this.input.value.trim()) {
      return;
    }
    this.props.addTop(this.input.value);
    this.input.value = "";
  };
  addToBottom = () => {
    if (!this.input.value.trim()) {
      return;
    }
    this.props.addBottom(this.input.value);
    this.input.value = "";
  };
  render() {
    return (
      <Fragment>
        <input ref={this.getInput} />
        <button onClick={this.addToTop}>Add to TOP</button>
        <button onClick={this.addToBottom}>Add to BOTTOM</button>
      </Fragment>
    );
  }
}

class Users extends PureComponent {
  render() {
    return (
      <ul>
        {this.props.names.map(user => (
          <Name
            id={user.id}
            onClick={this.props.onClick}
            highlighted={user.highlighted}
            key={user.id}
          >
            {user.name}
          </Name>
        ))}
      </ul>
    );
  }
}

class Name extends PureComponent {
  render() {
    return (
      <li
        className={this.props.highlighted ? "highlight" : ""}
        onClick={() => this.props.onClick(this.props.id)}
      >
        {this.props.children}
      </li>
    );
  }
}

render(<App />, document.getElementById("root"));


Note an unpleasant feature: if you select a person and then switch the team, the selection will be animated, although the person in the other team could never have been highlighted. Here is a striking example in the video:



When reusing keys where it is not necessary, we can get side effects, since the react will update, rather than delete and create new components.


This happens because we used identical keys for different people. And so the reagent uses the elements, although this is not necessary in the example. In addition, adding new people generates complex code.


There are a couple of problems in the above code:


  1. Data is not normalized, work with them is complicated.
  2. There is a duplication of keys with the developer entity, because of which the reaction does not recreate the component, but updates it. This leads to side effects.

There are two ways to solve the problem. A simple solution is to create a composite key for developers in the format:, ${id команды}.${id разработчика} this will allow you to not cross the keys and get rid of side effects.


But the problem can be solved comprehensively by normalizing data and combining entities. So, in the state component there will be 2 fields: teams , developers . developers will contain a map id + name , teams will have a list of developers who are on the team. We implement this solution:


class App extends Component {
  state = {
    active: 1,
    nextId: 3,
    developers: {
      "1": { name: "Миша" },
      "2": { name: "Саша" },
    },
    teams: [
      {
        id: 1,
        name: "Amazing Team",
        developers: [1]
      },
      {
        id: 2,
        name: "Another Team",
        developers: [2]
      }
    ]
  };
  addTop = name => {...};
  addBottom = name => {...}
  toggle = id => {
    this.setState(state => ({
      developers: {
        ...state.developers,
        [id]: {
          ...state.developers[id],
          highlighted: !state.developers[id].highlighted
        }
      }
    }));
  };
  switchTeam = id => {...};
  render() {
    // При реальной разработке вычисление списка людей лучше мемоизировать или вынести в computed value и хранить в state
    return (
      <Fragment>
        <TeamsSwitcher onSwitch={this.switchTeam} teams={this.state.teams} />
        <Users
          onClick={this.toggle}
          users={this.state.teams
            .find(team => team.id === this.state.active)
            .developers.map(id => ({ id, ...this.state.developers[id] }))}
        />
        <AddName addTop={this.addTop} addBottom={this.addBottom} />
      </Fragment>
    );
  }
}

Full example code with normalization
import React, { Component, PureComponent, Fragment } from "react";
import { render } from "react-dom";
import "./style.css";

class App extends Component {
  state = {
    active: 1,
    nextId: 7,
    developers: {
      "1": { name: "Миша" },
      "2": { name: "Екатерина" },
      "3": { name: "Валерий" },
      "4": { name: "Саша" },
      "5": { name: "Даниил" },
      "6": { name: "Марина" }
    },
    teams: [
      {
        id: 1,
        name: "Amazing Team",
        developers: [1, 2, 3]
      },
      {
        id: 2,
        name: "Another Team",
        developers: [4, 5, 6]
      }
    ]
  };
  addTop = name => {
    this.setState(state => ({
      developers: { ...state.developers, [state.nextId]: { name } },
      nextId: state.nextId + 1,
      teams: state.teams.map(
        team =>
          team.id === state.active
            ? {
                ...team,
                developers: [state.nextId, ...team.developers]
              }
            : team
      )
    }));
  };
  addBottom = name => {
    this.setState(state => ({
      // установку developers и nextId можно вынести в отдельную функцию, чтобы не дублировать код
      developers: { ...state.developers, [state.nextId]: { name } },
      nextId: state.nextId + 1,
      teams: state.teams.map(
        team =>
          team.id === state.active
            ? {
                ...team,
                developers: [...team.developers, state.nextId]
              }
            : team
      )
    }));
  };
  toggle = id => {
    this.setState(state => ({
      developers: {
        ...state.developers,
        [id]: {
          ...state.developers[id],
          highlighted: !state.developers[id].highlighted
        }
      }
    }));
  };
  switchTeam = id => {
    this.setState({ active: id });
  };
  render() {
    // При реальной разработке вычисление списка людей лучше мемоизировать или вынести в computed value и хранить в state
    return (
      <Fragment>
        <TeamsSwitcher onSwitch={this.switchTeam} teams={this.state.teams} />
        <Users
          onClick={this.toggle}
          users={this.state.teams
            .find(team => team.id === this.state.active)
            .developers.map(id => ({ id, ...this.state.developers[id] }))}
        />
        <AddName addTop={this.addTop} addBottom={this.addBottom} />
      </Fragment>
    );
  }
}

class TeamsSwitcher extends PureComponent {
  render() {
    return (
      <ul>
        {this.props.teams.map(team => (
          <li
            onClick={() => {
              this.props.onSwitch(team.id);
            }}
            key={team.id}
          >
            {team.name}
          </li>
        ))}
      </ul>
    );
  }
}

class AddName extends PureComponent {
  getInput = el => {
    this.input = el;
  };
  addToTop = () => {
    if (!this.input.value.trim()) {
      return;
    }
    this.props.addTop(this.input.value);
    this.input.value = "";
  };
  addToBottom = () => {
    if (!this.input.value.trim()) {
      return;
    }
    this.props.addBottom(this.input.value);
    this.input.value = "";
  };
  render() {
    return (
      <Fragment>
        <input ref={this.getInput} />
        <button onClick={this.addToTop}>Add to TOP</button>
        <button onClick={this.addToBottom}>Add to BOTTOM</button>
      </Fragment>
    );
  }
}

class Users extends PureComponent {
  render() {
    return (
      <ul>
        {this.props.users.map(user => (
          <Name
            id={user.id}
            onClick={this.props.onClick}
            highlighted={user.highlighted}
            key={user.id}
          >
            {user.name}
          </Name>
        ))}
      </ul>
    );
  }
}

class Name extends PureComponent {
  render() {
    return (
      <li
        className={this.props.highlighted ? "highlight" : ""}
        onClick={() => this.props.onClick(this.props.id)}
      >
        {this.props.children}
      </li>
    );
  }
}

render(<App />, document.getElementById("root"));


Now the elements are processed correctly:



Data normalization simplifies interaction with the application data layer, simplifies the structure and reduces complexity. For example, compare the toggle function with normalized and non-normalized data.


Hint : If the backend or api gives the data in a non-normalized format, you can normalize it with - https://github.com/paularmstrong/normalizr


To summarize the second part:


When using keys, it is important to understand that when changing data, keys must change. A vivid example of the errors that I met during the review, the use of the index of an element in an array like key . This leads to side effects, such as what we looked at when displaying a list of people with highlighting.


Normalization of data and / or components key allows you to achieve the desired effect:


  1. We update the data when the entity changes (for example, it is marked highlighted or mutates).
  2. Delete old instances if the element with the given one key no longer exists.
  3. Create new elements when needed.



Using key when rendering a single item


As we discussed, a react in the absence key compares the elements of the old and the new tree in pairs. If there are keys, it searches the list for the children desired item with the given key. The case when it children consists of only one element is not an exception to the rule.


Let's look at another example - notifications. Suppose that there can be only one notification in a specific period of time, is displayed for several seconds and disappears. It is simple to implement such a notification: the component that componentDidMount sets the counter, at the end of the counter, animates the hiding of the notification, for example:


class Notification1 extends PureComponent {
  componentDidMount() {
    setTimeout(() => {
      this.element && this.element.classList.add("notification_hide");
    }, 3000);
  }
  render() {
    return (
      <div ref={el => (this.element = el)} className="notification">
        {this.props.children}
      </div>
    );
  }
}

Yes, there is no feedback in this component, it does not cause any onClose , but it is not important for this task.


We got a simple component.


Imagine a situation - when you click on a button, a similar notification is displayed. The user clicks on the button without stopping, but after three seconds of notification the class will be added notification_hide and it will become invisible to the user (if we have not used key ).


To fix the component’s operation without using it key , create a class Notification2 that will be updated correctly using lifeCycle methods:


class Notification2 extends PureComponent {
  componentDidMount() {
    this.subscribeTimeout();
  }
  componentWillReceiveProps(nextProps) {
    if (nextProps.children !== this.props.children) {
      clearTimeout(this.timeout);
    }
  }
  componentDidUpdate(prevProps) {
    if (prevProps.children !== this.props.children) {
      this.element.classList.remove("notification_hide");
      this.subscribeTimeout();
    }
  }
  subscribeTimeout() {
    this.timeout = setTimeout(() => {
      this.element.classList.add("notification_hide");
    }, 3000);
  }
  render() {
    return (
      <div ref={el => (this.element = el)} className="notification">
        {this.props.children}
      </div>
    );
  }
}

Here we got a lot more code that restarts the timeout if the notification content has changed, and removes the class notification_hide when updating data.


But you can solve the problem, and using our first component Notification1 and attribute key . Each notification has its own unique id , which we will use as key . If, when the notification changes key , it Notification1 will be recreated. The component will correspond to the necessary business logic:




Thus


In rare cases, using a key single component when rendering is warranted. key - A very powerful way to "help" the reconciliation mechanism understand whether it is necessary to compare components or whether it is worth (immediately) creating a new one.




Working with keys when passing to children


An interesting feature key is that it is not available in the component itself. This is because key - special prop . In React, there are 2 special ones props : key and ref :


class TestKey extends Component {
  render() {
    // Выведет в консоли null
    console.log(this.props.key);
    // div будет пустым
    return <div>{this.props.key}</div>;
  }
}

const App = () => (
  <div>
    <TestKey key="123" />
  </div>
);

In addition, the console will display warning:


Warning: TestKey: key is not a prop. Trying to access it will result in undefined being returned. If you need to access the same value within the child component, you should pass it as a different prop. ( https://fb.me/react-special-props )

But, if the component has been transferred children that have it key , you can interact with them, but the field key will not be inside the object props , but at the component level:


class TestKey extends Component {
  render() {
    console.log(this.props.key);
    return <div>{this.props.key}</div>;
  }
}

class TestChildrenKeys extends Component {
  render() {
    React.Children.forEach(this.props.children, child => {
      // Доступ к key можно получить внутри child, при итерировании.
      // Как правило, такой хак может пригодиться только в исключительных ситуациях
      // Не нужно завязываться на key таким образом
      // Лучше задублировать ключ в другом prop
      console.log(child.key);

      // Все остальные props, кроме key и ref будут переданы в объекте props
      console.log(child.props.a);
    });
    return this.props.children;
  }
}

const App = () => (
  <div>
    <TestChildrenKeys>
      <TestKey a="prop1" key="1" />
      <TestKey a="prop2" key="2" />
      <TestKey a="prop3" key="3" />
      <TestKey a="prop10" key="10" />
    </TestChildrenKeys>
  </div>
);

The console will output:


1
prop1
2
prop2
3
prop3
10
prop10

To summarize:


key and ref - special props in the reaction. They are not included in the props object and are not available inside the component itself.


You can access child.key or child.ref from the parent component to which you passed children , but you do not need to do this. There are practically no situations where this is necessary. You can always solve the problem easier and better. If you need key to process it in a component, duplicate it, for example, in prop id .




We examined the scope of the key, how it is passed to the component, how the reconciliation mechanism changes with and without the key. And also looked at the use of key for elements that are the only child. At the end, we group the main points:


  1. Without a key mechanism, it reconciliation compares components in pairs between the current and the new VDOM. Because of this, a large number of unnecessary redraws of the interface can occur, which slows down the application.


  2. By adding key , you help the mechanism reconciliation by that key it does not compare in pairs, but searches for components with the same key (the tag / component name is taken into account) - this reduces the number of redraws of the interface. Only those elements that were changed / did not occur in the previous tree will be updated / added.


  3. Make sure that duplicates do not appear key ; when switching the display, the keys for the new data do not match. This can lead to unwanted side effects, such as animations, or incorrect element behavior.


  4. In rare cases, they key are used for one element. This reduces code size and understanding. But the scope of this approach is limited.


  5. key and ref - special props. They are not available in the component, they are not in child.props . You can access the parent through child.key , but there are practically no real applications for this. If in child components it key is necessary key - the right decision will be to duplicate in prop id , for example.