📜 ⬆️ ⬇️

Keys in React. Cook properly

Today we will talk about the key attribute in React. Often, developers who are just starting to use React do not attach much importance to the key attribute. And in vain ...


image
What does the ducky say when she found out that you are not using a key?


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


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

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 as examples in codesandbox, and under spoilers.




Reconciliation


The main task of the keys in the reactor is to help the mechanism of reconciliation. Let's create a small component that will render the 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 have not specified a single key. In the console we will see the message:


Warning: Each child should have a unique “key” prop.

Now let's complicate the task and create an input with the button for adding a new name to the beginning and to the end. In addition, in componentDidUpdate and DidMount for the Name of the component, we add logging of changes, 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 display change buttons (top center).


Demonstration of the work of such a list:



When adding an element from the top, we get a situation where the Name components will be redrawn and a new component created with children === :


Updated from Misha to Pavel
Updated from Daniel to Misha
Updated from Marina to Daniel
Updated from Basil to Marina
Mounted with Vasily

Why it happens? Let's look at the mechanism of reconciliation.




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


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


 oldTree: <div> <MyComponent /> </div> // MyComponent      newTree: <section> <MyComponent /> </section> 

It works in the same way 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, that is, the reactor simultaneously iterates over two arrays and compares the elements in pairs. That's why we got a redraw of all the elements in the list in the example with the names above. Let us consider an example:


 // oldTree <ul> <li></li> <li></li> </ul> // newTree <ul> <li></li> <li></li> <li></li> </ul> 

The reaction first checks <li></li> with each other, then <li></li> and at the end finds out that <li></li> not in the old tree. And create this item.


In the case when adding an item up:


 // oldTree <ul> <li></li> <li></li> </ul> // newTree <ul> <li></li> <li></li> <li></li> </ul> 

The reaction compares <li></li> with <li></li> - updates it. Then compare <li></li> with <li></li> - update it and at the end create <li></li> . When you insert an element at the beginning, the React will update all the elements in the array.




To solve a problem in a reactor, use the key attributes. When adding a key, React will not compare items one after another, but will search by key value. The example with the rendering of 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> 

The reaction finds key='1' , key='2' , determines that no changes have occurred with them, and then finds the new element <li key='3'></li> and adds only it. Therefore, with keys, only one component will be updated.


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



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


To summarize the first part:


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




Key Reuse and Normalization


Let's complicate the task. Now we will create a list of not abstract people, but a list of people - members of the development team. The company has two teams. Team members can be identified by clicking the mouse. Let's try to solve the problem "in the forehead." Try to select 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 the unpleasant feature: if you select a person and then switch the command, the selection will be animated, although the person in the other team could never be selected. Here is a vivid example in the video:



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


This happens because we used identical keys for different people. And therefore, the reactor reuses elements, although in the example it is not necessary. In addition, adding new people creates complex code.


In the above code there are a couple of problems:


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

There are two ways to solve the problem. A simple solution would be to create a composite key for developers in the following format: ${id }.${id } this will allow the keys not to intersect and get rid of side effects.


But the problem can be solved in a complex way, by normalizing the data and combining the entities. So, in the state component there will be 2 fields: teams , developers . developers will contain the id + name map, teams will have a list of developers who are in 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 code example 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 data layer of the application, simplifies the structure and reduces complexity. For example, compare the toggle function with normalized and unnormalized data.


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


To summarize the second part:


When using keys, it is important to understand that when changing data, the keys should change. A vivid example of the errors I encountered during a review is using the index of an element in an array as key . This leads to side effects like what we saw in the example of displaying a list of people with a selection.


Data normalization and / or composite key allow you to achieve the desired effect:


  1. We update the data when the entity changes (for example, it is marked selected or mutated).
  2. Delete old instances if the element with the given key no longer exists.
  3. Create new items when you need it.



Using key when rendering one item


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


Let us consider another example - notifications. Suppose that there can be only one notification for a specific period of time, is displayed for a few seconds and disappears. This notification is simple to implement: a component that sets a counter on the componentDidMount , 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 for this task it does not matter.


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 ceasing, but after three seconds of notification the notification_hide class will be added, and it will become invisible to the user (if we did not use the key ).


To fix the component without using the key , create a class Notification2 , which 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 notification_hide class when updating data.


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




In this way


In rare cases, using a key when rendering one component is justified. key - a very powerful way to “help” the reconciliation mechanism to understand whether to compare components or whether you should immediately (re) create a new one.




Working with keys when passing to the children component


An interesting key feature is that it is not available in the component itself. This is because the key is a special prop . In React, there are 2 special 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 be warning:


Warning: TestKey: key is not a prop. Trying to be returned. It is a different prop. ( https://fb.me/react-special-props )

But, if children who have a key passed the component, you can interact with them, but the key field will not be inside the props object, but at the level of the component itself:


 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 display:


one
prop1
2
prop2
3
prop3
ten
prop10

Summing up:


key and ref - special props in the reactor. They are not included in the props object and are not accessible within the component itself.


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




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


  1. Without a key the reconciliation mechanism checks the components in pairs between the current and the new VDOM. Because of this, there may be a large number of unnecessary redrawing of the interface, which slows down the application.


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


  3. Make sure that no duplicate key appear, when switching the display of new data, the keys do not match. This can lead to undesirable side effects, such as animation, or incorrect logic of the element's behavior.


  4. In rare cases, key used for a single item. This reduces the size of the code and simplifies 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 get access to the parent through child.key , but there are practically no real applications for this. If a key is needed in the child components, the correct solution would be to duplicate it into a prop id , for example.

')

Source: https://habr.com/ru/post/352150/


All Articles