Les travers des keys en React, explications

Pour moi, l'utilisation des clés (keys) en React est l'un des aspects les plus délicats à comprendre lorsque l'on commence à développer avec cette technologie. Même après plusieurs mois, on ne se rend pas bien compte de leur importance car le code s'exécute correctement la plupart du temps, même sans ces clés.

C'est pour moi l'un des aspects les plus étranges rencontrés lors de ma première année à développer avec React.

Un problème de réconciliation

Pour mettre à jour la vue de votre application, React doit mapper le virtualDOM au DOM du navigateur et réaliser les remplacements nécéssaires. Lorsque l'on utilise une hiérarchie bien définie dans le JSX, c'est simple : on mappe le premier composant avec la première node, puis la seconde, etc etc. Le problème se complique lorsque l'on génère les composants dynamiquement. Impossible en comparant les deux arbres DOM de savoir quel noeud correspond à quoi. D'autant que ces noeuds peuvent avoir changé d'ordre ou de contenu !

Dans ces cas la, React nous force à implémenter un système de key. Si on ne le fait pas, il nous gratifie d'un joli avertissement dans la console. D'après la documentation officielle, les keys doivent permettre d'identifier un composant entre deux rendus de notre Application. Elles doivent être uniques et en plus référencer toujours le même objet dans notre liste.

C'est beau la théorie, mais en pratique on se retrouve souvent avec des listes mais on ne sait pas trop quoi mettre comme key. Surtout que parfois le backend ne nous fournit pas d'ID, ou celui-ci n'est pas unique.

Jouons un peu avec les index

La première solution pour trouver une key est de mettre l'index de l'élément dans le tableau. A première vue, cela fonctionne, on n'a plus de warning dans la console, et avec de la chance la fonctionnalité continue à respecter nos specs.

Attention spoiler: Notre code fonctionne à première vue seulement...

Pour mieux illustrer, prenons cet exemple où on génère une liste d'items. La liste dispose d'un bouton pour ajouter un nouvel item au début de la liste. On utilise les indexes de la liste comme key pour chaque item.

class Item  extends React.Component {
	state = {
    text: this.props.text
  }
  
  onChange = event => {
    this.setState({
      text: event.target.value
    })
  }

  render() {
    const { text } = this.state;
    return (
      <li>
        <input value={text} onChange={this.onChange} />
      </li>
    )
  }
}

class App extends React.Component {
  state = {
    items: [
      {
        text: "First",
        id: 1
      },
      {
        text: "Second",
        id: 2
      }
    ]
  };

  addItem = () => {
  	// Here we push a new "Front" item at the end of the list, 
    //    but instead react duplicate the last element at the end of the list
    const items = [{ text: "Front", id: Date.now() }, ...this.state.items];
    this.setState({ items });
  };

  render() {
    console.log("Notre state", this.state);
    return (
      <div>
        <ul>
          {this.state.items.map((item, index) => (
          	// Try by setting key={item.id} here ?
            <Item {...item} key={index} />
          ))}
        </ul>
        <button onClick={this.addItem}>Add Item</button>
      </div>
    );
  }
}

ReactDOM.render(
  <App />,
  document.getElementById('container')
);

Vous pouvez tester le code suivant ici : Edit 0owm70qk2n

Si on utilise index comme key, on remarque vite que le fonctionnement n'est pas correct.

Dans cet exemple, on a defini correctement les key.React comprend donc qu'il doit ajouter un élément en debut de liste.Le state affiché à droite correspond bien à ce que l'on veut, mais notre affichage n'est pas bon ! Voila notre bug !{
    "items": [
        {
            "text": "First",
            "id": 1
        },
        {
            "text": "Second",
            "id": 2
        }
    ]
}

Lorsqu'un nouvel Item avec text: 'Front' est ajouté en tête de liste, le state du composant App est modifié. Si on regarde l'affichage de Notre State on constate bien l'ajout. React utilise l'index pour différencier les composants, c'est-à-dire qu'il va réutiliser les deux premiers (indexes 0 et 1) et leur passer les props correctes : respectivement Front et First. Il va ensuite croire que l'élément 2 est nouveau et va donc le créer au lieu de deviner qu'il s'agit en fait de l'ex-élément numéro 1. Pour rajouter à notre malchance, les deux premiers éléments utilisent leur state interne et non leurs props pour leur rendu (Front est mappé à l'ex composant First et donc garde son ancien state).

Et voilà comment on crée un bug qu'on mettra 2 jours à tracer et à fixer... Sad True Story...

Image triste, code pendant 6 minutes, debug pour 6 heures

Maintenant un autre petit jeu, prenez votre code, et faites une recherche sur key={index} ou key={i} pour voir combien de bugs potentiels trainent. J'ai pris une application sur laquelle je travaille et j'ai 43 matchs. Qui dit mieux ?

Corriger avant qu'il ne soit trop tard

Désolé pour ceux qui espèrent un fix à chercher/remplacer dans leur code, je cherche encore une solution systématique à appliquer partout et je doute de la trouver un jour.

En attendant, j'ai quand même quelques pistes.

La plus simple, c'est d'utiliser un ID fourni par le backend. L'avantage c'est qu'en théorie cet ID est unique, et avec de la chance il est déjà à portée de de main ! Par contre si votre backend ne vous en fournit pas, et que vous n'avez pas la main dessus, il va falloir lire la suite...

Une autre solution pour identifier chaque objet est de leur assigner un ID lorsque l'on les charge via un simple forEach(array, (item, index) => item.id=index). En cas de changement d'ordre dans l'array coté react, l'ID reste persistant et le fonctionnement est correct. Lors du rajout d'items à la liste on à juste a leur ajouter un ID avec un Date.now() et on gére tous les cas. C'est la solution proposée en commentaire dans le code précédent. L'inconvénient c'est que si on veux agréger plusieurs tableaux, on aura forcément des IDs en double.

Une troisième solution, piquée de java, consiste à générer une signature propre à chaque objet. On peut par exemple faire un MD5 du JSON.stringify de notre objet. L'avantage c'est qu'on a une string pseudo-aléatoire, qui identifie notre objet de façon unique. On est pas non plus obligé de stocker cette valeur car soit l'objet n'a pas changé et on retrouvera la même, soit l'objet change et on obtiendra un nouveau hash (et donc une destruction puis création de composant coté React)

Enfin, la derniére solution, à appliquer en tout dernier recours lorsqu'aucune des précédentes n'est appliquable : le Math.random() ! On va tout simplement recréer chaque key à chaque rendu avec un key={Math.random()}. Cela force React à regénérer toute la liste de composants à chaque fois. Niveau performances, consistance et propreté c'est totalement déconseillé ! Mais au moins ça fonctionne dans 100% des cas !

Si on applique l'un des fixes sur notre code, on se retrouve avec le bon fonctionnement:

Dans cet exemple, on a defini correctement les key.React comprend donc qu'il doit ajouter un élément en debut de liste.Le state affiché à droite correspond maintenant à notre affichage !{
    "items": [
        {
            "text": "First",
            "id": 1
        },
        {
            "text": "Second",
            "id": 2
        }
    ]
}

Et vous ? Quel genre de bizarreries React vous a fait subir ?

Qu'avez vous pensé de cet article?