Pourquoi push() rend l’interface React obsolète et comment de nouvelles références y remédient
Comprenez pourquoi la modification des tableaux dans useState ne déclenche jamais un re-render, comment fonctionne la vérification des références chez React, et quand utiliser des copies étendues et des mises à jour fonctionnelles.
Sources: React Survival Guide, Part 2: Don’t Mutate! Why `.push()` is Killing Your UI
Rares sont les bugs React aussi déroutants qu’une interface utilisateur qui refuse de se mettre à jour alors que la console affirme que les données sont correctes. Il n’y a ni erreur ni avertissement, seulement un écran bloqué sur des valeurs anciennes. Dans la grande majorité des cas, la cause réside dans une mutation d’état : modifier un tableau ou un objet sur place au lieu de le remplacer. Cet article explique précisément pourquoi React ignore les modifications sur place, présente la solution en une seule ligne, et aborde la méthode d mise à jour fonctionnelle nécessaire lorsque les mises à jour s’accumulent rapidement.
Le bug : les données changent, l’écran non
Imaginez un petit panier d’achat, une liste de tâches ou un tableau d’utilisateurs. La dernière étape consiste à ajouter un élément dans un tableau géré par useState, et la première tentative ressemble à du JavaScript ordinaire.
const [cart, setCart] = useState(['Coffee']);
const addItem = (newItem) => {
// It's just JavaScript, right?
cart.push(newItem);
console.log("Added! Cart is now:", cart);
};
Lorsque l’on clique sur « Ajouter au panier », le journal d’erreurs affiche ['Coffee', 'Tea'], ce qui indique que les données semblent correctes. Cependant, le panier affiché ne liste que « Coffee ». L’interface montre donc aux utilisateurs des informations qui ne sont plus vraies, ce qui est pire qu’une panne, car personne ne s’en aperçoit avant qu’un client ne le fasse. Dans de telles situations, les développeurs passent souvent une heure à vérifier à nouveau des boucles et de la logique d’affichage qui n’étaient jamais le problème.
Remarquez que ce gestionnaire ne lance jamais setCart. Rien n’indique à React qu’un événement s’est produit, donc il n’a aucune raison de mettre à jour l’affichage. Une version plus subtile du bug ajoute setCart(cart) après le push(), mais l’écran ne change toujours pas. La section suivante explique pourquoi.
React compare les références, pas le contenu
L’idée fondamentale à retenir est simple : React suit l’identité, pas le contenu.
Des méthodes telles que .push(), .pop(), .splice() et .unshift(), ou une affectation comme user.name = 'Alice', constituent des mutations. Elles indiquent à JavaScript de modifier la valeur stockée en un emplacement existant dans la mémoire. JavaScript le fait immédiatement, d’où le fait que console.log(cart) affiche l’élément nouveau. React, cependant, ne surveille pas cette mémoire. Son modèle de mise à jour suppose que l’état est considéré comme immuable.
Lorsque vous appelez un setteur d’état, React compare la nouvelle valeur avec l’ancienne en utilisant Object.is. Pour les tableaux et les objets, il s’agit d’une comparaison de référence : on vérifie simplement s’il s’agit du même objet que précédemment, sans examiner si son contenu a changé. Après un appel à push(), le tableau que vous renvoyez est le même objet que celui déjà conservé par React. La vérification indique « inchangé », React saute la re-renderisation, et l’interface continue d’afficher la dernière version enregistrée.
Cette vérification rapide est intentionnelle. Comparer des références prend du temps constant, quel que soit la taille de l’état, tandis qu’une comparaison approfondie de chaque valeur imbriquée à chaque mise à jour serait coûteuse. Le compromis consiste à devoir signaler un changement en fournissant une nouvelle référence.
La solution : copier, modifier la copie, définir la copie
L’immutabilité semble plus exigeante qu’elle ne l’est en réalité. Pour mettre à jour un tableau ou un objet dans l’état, il suffit de laisser le original intact, de créer un nouveau qui intègre la modification, puis de le transmettre au setter. En JavaScript moderne, l’opérateur de propagation (...) est le moyen le plus rapide pour ce faire.
La version corrigée de addItem crée un nouveau tableau contenant les anciens éléments suivis du nouvel élément ajouté.
const addItem = (newItem) => {
// The right way (creating a new identity):
setCart([...cart, newItem]); // Create a new array, add newItem at the end.
};
Désormais, le setter reçoit une référence différente, la vérification avec Object.is échoue, et React ré-redémarre l’affichage avec l’élément ajouté. Le même principe s’applique aux autres opérations :
- Suppression :
cart.filter(item => item !== target)au lieu desplice(). - Remplacement :
cart.map(item => (item.id === id ? { ...item, qty: item.qty + 1 } : item)).
setUser({ ...user, name: 'Alice' }) au lieu d’attribuer une valeur à une propriété.[...cart].sort(), ou utilisez les méthodes non mutantes toSorted() et toReversed() si vos navigateurs cibles les prennent en charge.N’oubliez pas que la copie par diffusion ne fonctionne qu’à un seul niveau. Si l’état contient des objets imbriqués, chaque niveau modifié nécessite sa propre copie. Pour en savoir plus sur la mutation de référence au-delà de l’état React, y compris les structures imbriquées, consultez éviter les bugs d’état silencieux dus à la mutation de référence en JavaScript.
Les avantages des mises à jour immuables
Ce n’est pas seulement une règle imposée par React ; cela permet d’obtenir du code plus facile à comprendre.
- Points de mise à jour clairs. Chaque modification d’état passe par un setter, ce qui permet de savoir facilement quelles fonctions modifient l’état et où.
- Débogage amélioré. Comme chaque mise à jour génère une valeur distincte, React DevTools et d’autres outils peuvent afficher l’état avant et après chaque modification.
- Aucun effet secondaire caché. Puisque les données ne sont jamais modifiées sur place, une erreur dans un composant ne peut pas corrompre discrètement un tableau ou un objet que une autre partie de l’application lit encore.
Mises à jour fonctionnelles pour des modifications rapides ou simultanées
Un second problème apparenté survient avec des mises à jour fréquentes, telles que des clics répétés rapidement ou plusieurs réponses asynchrones arrivant très près les unes des autres, éventuellement dans le mauvais ordre. Chaque gestionnaire capture la valeur d’état provenant de l’affichage dans lequel il a été créé. Si deux mises à jour s’exécutent avant un nouvel affichage, elles peuvent toutes deux partir de la même capture obsolète, et l’une des modifications écrase l’autre. C’est le problème de la fermeture obsolète, qui interagit directement avec la manière dont vous créez de nouveaux états.
La solution consiste à passer une fonction au setteur. React l’appelle avec l’état en attente le plus récent, de sorte que chaque mise à jour s’appuie sur le résultat de la précédente plutôt que sur une valeur capturée. L’exemple ci-dessous ajoute un lot d’éléments générés. Il lit prevCart.length dans l’argument pour les numéroter, puis renvoie un nouvel array qui combine les éléments précédents avec les nouveaux.
// A very fast counter:
const addManyItems = (count) => {
// Good: Uses spread to be immutable
// Better: Uses functional update for consistency
setCart(prevCart => {
// We are generating new array content:
const newItems = Array.from({ length: count }, (_, i) => `Item ${prevCart.length + i + 1}`);
// Spread operator for the final, clean, new identity:
return [...prevCart, ...newItems];
});
};
Deux éléments agissent ici ensemble. La propagation crée toujours une nouvelle référence, ce qui pousse React à rérenderiser l’élément. La fonction de mise à jour garantit que le tableau de base est celui le plus récent, ce qui empêche les mises à jour d’être perdues. Une bonne pratique consiste à utiliser la forme fonctionnelle chaque fois que l’état suivant est calculé à partir de l’état précédent.
En résumé
- Une mutation modifie les données mais pas la référence, de sorte que la vérification
Object.isde React conclut qu’aucun changement n’a eu lieu et saute la rérenderisation. - Lorsque l’interface utilisateur est en retard par rapport aux données, cherchez d’abord des appels à
.push(),.unshift(),.splice()oustate.property = valuesur l’état. - Créez un nouveau tableau ou objet à l’aide de la propagation, de
mapou defilter, et copiez chaque niveau imbriqué que vous modifiez.
La correction nécessite généralement une seule ligne de code, et c’est ce qui fait la différence entre une interface en laquelle les utilisateurs peuvent avoir confiance et une autre qui affiche silencieusement des informations obsolètes.