Lorsque la mémorisation de React tourne mal : pourquoi moins de rendus ont ralenti l’application.
Lorsque la mémorisation de React a des effets néfastes : memo, useMemo et useCallback ont ajouté des coûts et des closures obsolètes à une page volumineuse. Déplacer l’état et analyser le flux ont résolu le problème.
Un cas de correction d’une erreur
Imaginez une grande page qui se réaffiche à chaque fois que quelqu’un tape dans un filtre. Elle contient des tableaux, des graphiques, des résumés ainsi que plusieurs panneaux interactifs, et le React Profiler affiche une longue liste de composants. Cela semble prouver que React effectue beaucoup trop de travail.
La solution habituelle est alors appliquée : les composants sont enveloppés dans memo, les gestionnaires d’événements dans useCallback, les calculs dans useMemo, et les sélecteurs disposent de fonctions d’égalité personnalisées. Un composant qui se réaffiche encore voit ses props davantage stabilisés. Un objet dont la référence change est mémorisé. Une comparaison qui échoue est remplacée par une comparaison plus approfondie.
Finalement, l’outil d’analyse montre bien moins de rendus de composants. Cependant, l’application ne semble pas plus rapide. Certaines interactions deviennent moins réactives, certaines écrans consomment plus de mémoire, et quelques composants affichent des valeurs obsolètes parce qu’une dépendance a été omise afin de maintenir une référence stable. Le débogage devient également plus difficile, puisque les performances dépendent désormais des caches et des règles de comparaison dispersées dans l’arborescence. L’équipe a ajusté un paramètre facile à observer, mais pas celui qui compte réellement pour les utilisateurs.
L’utilisation de la mémorisation n’était pas l’erreur, puisque React propose ces outils pour résoudre des problèmes réels. La faille résidait dans l’idée selon laquelle arrêter un rendu vaut toujours mieux que de le permettre. Le rendu exige des efforts, tout comme la décision de l’omettre. Lorsque cette décision implique de reconstruire des tableaux de dépendances, de comparer de gros props, de conserver des valeurs anciennes et d’exécuter une logique de cache personnalisée, l’optimisation peut finir par coûter plus cher que le travail qu’elle était censée éviter.
Que signifie vraiment « inutile »
La première erreur résidait dans la définition d’un rendu inutile. Un composant apparaissait dans l’analyseur même si son output DOM final n’avait pas changé visiblement, ce qui faisait qualifier ce rendu de gaspillage. Cela ignore à quel point de nombreux composants sont peu coûteux à exécuter.
Prenez un composant fonctionnel qui lit quelques propriétés primitives et renvoie un arbre d’éléments simple. Son exécution est instantanée, React compare les résultats et ne modifie presque rien dans le DOM, donc bloquer cette appelation ne permet d’économiser pratiquement rien.
La version mémorisée a quant à elle une tâche spécifique à accomplir. React doit conserver les propriétés précédentes, recevoir les nouvelles et les comparer. Lorsqu’il s’agit de plusieurs propriétés, dont certaines sont des objets, des tableaux ou des fonctions, la comparaison peut échouer fréquemment ou devenir suffisamment coûteuse pour annuler tout avantage. Le nombre de rendus est devenu en pratique un indicateur de performance, et un chiffre plus bas était considéré comme meilleur, même si ce n’étaient pas les rendus sautés qui causaient le retard.
C’est un piège facile, car le profileur rend visible le processus de rendu tandis que d’autres coûts restent cachés. La comparaison des props, l’allocation en JavaScript, le maintien en cache, les sélecteurs de stockage, le layout, le dessin ainsi que les bibliothèques tierces peuvent dominer une interaction, tandis que le nombre de rendus attire toute l’attention. Il vaut la peine d’éviter un nouveau rendu lorsque le composant effectue un travail significatif et que ses entrées restent souvent identiques. Il ne vaut pas la peine de l’éviter simplement parce qu’il s’est produit.
Quel est réellement le coût de memo
Les gens décrivent souvent memo comme un mécanisme qui empêche un enfant de se rerendre lorsque les props ne changent pas. Cette description omet une étape : React doit d’abord comparer les anciens props aux nouveaux avant de pouvoir sauter quoi que ce soit.
Avec des props primitifs, la comparaison est peu coûteuse. Avec des contrats de composants complexes, il est plus difficile de prévoir les coûts. Des objets de ligne, des tableaux de configuration, des modèles de permissions, des collections de fonctions de rappel et des options imbriquées étaient tous transmis. En enveloppant le récepteur dans memo, aucune de ces valeurs n’a été stabilisée. Cela a simplement ajouté une étape supplémentaire où React vérifiait leur stabilité.
La plupart de ces vérifications ont échoué, car le composant parent a reconstruit au moins une référence pendant le rendu. React a effectué la comparaison, puis a tout de même rendu, ce qui a entraîné une augmentation des efforts sans qu’aucun élément ne soit supprimé.
Des fonctions de comparaison personnalisées ont été mises en place afin d’examiner plus attentivement les valeurs. Un composant de grille de données vérifiait ses colonnes, les lignes sélectionnées, les détails de pagination, le fait que le chargement était en cours, ainsi que les lignes elles-mêmes. Ce comparateur devenait ainsi une seconde copie du modèle de dépendances du composant, et il pouvait échouer de deux manières : une vérification approfondie consommait du temps réel, tandis qu’une vérification sommaire risquait d’omettre un rendu nécessaire.
Une nouvelle frontière de correction existait désormais. Tout ce qui influençait le rendu devait être reflété dans le comparateur ; par conséquent, ajouter une propriété impliquait d’éditer à la fois le composant et sa logique de mémorisation. Les composants qui se rendaient correctement par défaut ne restaient corrects que tant qu’une fonction d’égalité soigneusement gérée les décrivait sans erreur.
Comparaison approfondie
Lorsque les comparaisons superficielles échouent trop souvent, une comparaison approfondie semble être la prochaine étape évidente. Si deux objets de configuration contiennent les mêmes valeurs, pourquoi des références différentes devraient-elles forcer une mise à l’écran ? Comparez leur contenu et sautez cette étape s’ils sont structurellement identiques.
Cela fonctionne bien dans les démos avec de petits objets. Dans une application réelle, certaines propriétés contiennent des centaines ou des milliers de valeurs, et une comparaison approfondie parcourt des tableaux, examine des objets imbriqués et répète tout cela à chaque mise à jour des parents. Le composant protégé ne peut afficher que quelques lignes légères, ou bien retourner un composant tiers doté de sa propre mémoisation pour les sous-arbres stables. La vérification d’égalité peut coûter plus cher que l’appel au composant lui-même.
La comparaison approfondie évolue également en fonction de la taille de l’entrée et non de la taille du changement. Une seule petite mise à jour d’état peut obliger le comparateur à examiner toute une collection pour prouver que la majeure partie n’a pas changé.
La structure n’est pas non plus toujours une base fiable pour les comparaisons. Les fonctions, les dates, les cartes, les instances de classe et les objets de bibliothèque peuvent présenter un comportement ou une identité que une fonction de comparaison de profondeur générique ne peut pas interpréter, de sorte qu’une comparaison qui semble complète peut conserver un comportement obsolète. L’équipe a abandonné le modèle de référence simple de React au profit d’un système personnalisé de comparaison sémantique, sans jamais définir ce que signifiait l’égalité pour chaque valeur de domaine.
La solution la plus efficace consistait rarement à comparer plus rigoureusement des objets instables. Il s’agissait plutôt de préserver naturellement leur identité lorsque les données sous-jacentes n’avaient pas changé, de restreindre le contrat des propriétés, ou de rapprocher la mise à jour du composant qui en était responsable.
Mémorisation des valeurs
Les valeurs ont suivi. Les tableaux filtrés, les objets de configuration, les étiquettes formatées, les tableaux de recherche et de petits calculs recevaient tous la fonction useMemo une fois que les composants qui les entouraient avaient été mémorisés.
Quelques-uns le méritaient vraiment. Le tri d’une grande collection ou l’assemblage d’un modèle coûteux peuvent justifier le stockage en mémoire si les entrées changent moins souvent que le rendu du composant parent. Beaucoup d’autres, en revanche, ne le justifiaient pas. La concaténation de deux chaînes de caractères ou la création d’un petit objet sont moins coûteuses que l’infrastructure nécessaire pour les gérer : le composant construit toujours un tableau de dépendances, React compare toujours chaque élément avec le précédent, le résultat précédent occupe toujours de la mémoire, et les développeurs doivent toujours déterminer si la liste est complète.
Le code est également devenu visuellement plus lourd. Une valeur qui se décrivait autrefois en une seule expression se retrouvait désormais enveloppée dans un Hook et un tableau de dépendances.
const label = useMemo(
() => `${firstName} ${lastName}`,
[firstName, lastName]
);
Lors de l’exécution, le cache ne contribuait presque pas, ce qui indiquait que le calcul n’était pas suffisamment important pour être optimisé. Pire encore, l’utilisation généralisée de useMemo obscurcissait le flux des données. Les développeurs maintenaient les références aux objets stables non pas parce que leur création était coûteuse, mais parce que d’autres composants mémorisés dépendaient de leur identité. Chaque optimisation nécessitait une autre, ce qui créait un arbre de caches fonctionnel uniquement tant que tous les liens étaient intacts.
La mémorisation est le plus utile lorsqu’elle permet de conserver des opérations coûteuses. Ici, elle servait à préserver des références nécessaires à d’autres mécanismes de mémorisation, ce qui constitue un objectif complètement différent.
Appels de fonction et fermetures obsolètes
Les appels de fonction créés pendant le rendu obtiennent une nouvelle identité à chaque fois. Transmis aux enfants mémorisés, ces nouvelles références font échouer la comparaison de ces derniers. La solution a été d’encadrer presque tous les gestionnaires dans useCallback.
Cela a réduit certains changements de références, mais a entraîné des listes de dépendances plus longues. Un gestionnaire d’enregistrement dépendait du brouillon, de l’enregistrement sélectionné, de l’utilisateur actuel, des permissions, d’un service de notification ainsi que de la fonction de mutation ; toute modification de l’un de ces éléments générait de toute façon un nouveau callback. Ce dernier restait rarement stable suffisamment longtemps pour être réutilisé. L’élément enfant s’affichait néanmoins, et l’élément parent devait gérer un Hook supplémentaire ainsi qu’un contrat de dépendance de plus.
Les développeurs suppriment parfois des dépendances afin de maintenir la stabilité des appels de fonction, ce qui crée des closures obsolètes. Un appel de fonction continue de lire une version ancienne, une valeur de permission dépassée ou un enregistrement précédemment sélectionné, car préserver l’identité de la fonction devient plus important que de conserver son comportement actuel. Un tel code peut réussir les tests lors de la première interaction, mais échouer uniquement après un changement d’état sans que la fonction d’appel ne soit recréée. Ces bugs sont difficiles à détecter, car la fonction existe et s’exécute sans erreur ; elle agit simplement en se basant sur des données anciennes. L’identité des appels de fonction a été optimisée au détriment de la fraîcheur des données.
Une question plus pertinente est de savoir si l’enfant a réellement besoin d’une fonction de rappel stable. Pour un enfant peu coûteux qui n’est pas mémorisé, useCallback ne sert pas à grand-chose. Si la fonction de rappel alimente une abonnement ou une structure mémorisée coûteuse, la stabilité devient importante, et ses dépendances peuvent souvent être réduites en changeant le propriétaire, en passant une opération plus restreinte ou en plaçant ce comportement plus près de l’état qu’il lit. Une fonction ayant une identité fixe n’est pas naturellement plus rapide ; elle ne s’avère utile que lorsque quelque chose de significatif la réutilise.
Les caches utilisent de la mémoire
Le caching évite les calculs répétés en conservant les résultats précédents, et cette conservation a un coût en termes de mémoire. Une petite valeur mémorisée ne constitue rarement à elle seule un problème de mémoire. Cependant, dans une application volumineuse, de nombreux composants peuvent contenir des tableaux, des dictionnaires, des enregistrements transformés, des modèles formatés ainsi que des fonctions fermées qui captent davantage de données.
C’est en partie pour cette raison qu’une seule vue de profilage ne peut pas décrire les performances. Un calcul mémorisé peut disparaître de la chronologie de rendu, tout en entraînant une augmentation de la mémoire et des pauses plus longues par la suite. Le fait que cela en vaille la peine dépend de la taille du résultat, des coûts de reconstruction, de la fréquence à laquelle il est réutilisé et de la durée pendant laquelle le composant reste activé. Un grand modèle dérivé mémorisé pour un panneau rarement ouvert peut coûter plus cher que de le reconstruire lorsque c’est nécessaire. Les caches ne constituent pas un espace de stockage gratuit pour les travaux anciens ; ils substituent des calculs à l’état conservé, ainsi qu’une logique de validation plus complexe.
Le véritable problème résidait dans l’emplacement de l’état
La découverte clé a été que les re-rendus reflétaient l’endroit où l’état était stocké. Un filtre en constante évolution se trouvait dans le composant de page, qui rendait également les graphiques, les résumés, un grand tableau et plusieurs panneaux. Chaque frappe de touche provoquait un re-rendu de la page et une reconstruction des propriétés pour chaque enfant.
Protéger les enfants grâce à la mémorisation signifiait stabiliser leurs tableaux, objets et fonctions de rappel, ainsi que les sélecteurs et comparaisons personnalisés, tandis que l’architecture restait axée sur une seule frontière d’actualisation globale. En déplaçant l’état du filtre à côté du tableau, la plupart des problèmes ont disparu, et le reste de la page n’avait plus besoin de se protéger.
Ailleurs, la même chose s’est produite : l’état des formulaires de dialogue se trouvait dans le composant d’écran, l’état de survol existait au-dessus d’une collection entière de cartes, un contexte contenait des valeurs ayant des taux d’actualisation très différents, et un sélecteur de stockage global renvoyait plus d’informations que ce dont avait besoin son utilisateur. La mémorisation masquait les choix de gestion des propriétés qui permettaient à de petites modifications de se propager trop loin.
Lorsque ces contraintes ont été renforcées, de nombreux niveaux de mémorisation sont devenus inutiles. Les composants hors du chemin de mise à jour ont cessé de s’afficher, car l’état ne se trouvait plus au-dessus d’eux, et les enfants ont bénéficié de contrats plus simples, le parent ne les gérant plus de manière minutieuse.
Moins de rendus, même charge pour le navigateur
Même lorsque la mémorisation évitait certains rendus React, les interactions restaient parfois lentes, car React n’était pas la principale source de coût.
Une bibliothèque de graphiques continuait d’effectuer des mises à jour coûteuses de type impératif. Un grand arbre DOM nécessitait toujours un réaménagement lorsqu’une dimension changeait. Un effet CSS forçait de larges réredessins, une écriture dans le stockage synchrone bloquait le thread principal, et un gestionnaire d’entrée analysait un document volumineux avant même que le caractère suivant ne puisse apparaître. Les composants avaient attiré l’attention uniquement parce que c’était ce que le profileur mesurait, et à mesure que le rendu devenait moins coûteux, le travail effectué par le navigateur en arrière-plan devenait visible.
Les performances de React ne représentent qu’une partie des performances du front-end, et ne peuvent pas le remplacer. Même avec la mémorisation en place, une dépendance d’Effect modifiée peut toujours amener un widget tiers à se recompiler. Omettre l’affichage d’un élément parent n’abrége pas le temps de décodage d’une image trop volumineuse. Un callback stable ne résoudra pas les problèmes de layout dus à une lecture et une écriture des mesures des éléments dans le mauvais ordre. Il faut suivre l’ensemble du processus : JavaScript, rendu de React, mutation du DOM, calcul des styles, mise en page, affichage et réseau, car accélérer une seule étape peut ne pas modifier l’ensemble du parcours. React est resté silencieux tandis que le navigateur continuait à travailler intensément.
Code plus difficile à modifier
Le coût en termes de performances n’était qu’une moitié du problème. L’application est également devenue plus difficile à maintenir.
Chaque limite de composant soulevait de nouvelles questions. Le composant est-il mémorisé ? Quelles propriétés nécessitent des références stables ? Un comparateur personnalisé couvre-t-il la nouvelle propriété ? Une valeur a-t-elle été délibérément omise de la liste des dépendances ? Modifier la structure d’un objet annulera-t-il plusieurs caches ? Le travail habituel sur les fonctionnalités exigeait de comprendre des contrats d’optimisation qui n’avaient rien à voir avec le comportement du produit.
Imaginez un développeur qui ajoute un champ à un objet de configuration et déclenche accidentellement de nombreux re-renderings. Un collègue « corrige » cela en utilisant un comparaison plus poussée. Plus tard, quelqu’un ajoute une fonction à l’objet et constate que le comparateur traite deux configurations clairement différentes comme égales. Le code avait été ajusté pour sa forme actuelle, et toute modification perturbait un réseau délicatement équilibré de références et d’arrays de dépendances.
Une architecture à bonne performance rend les modifications courantes moins coûteuses et n’oblige pas chaque changement à respecter des hypothèses de cache cachées. Cela ne signifie pas pour autant que le code optimisé doive rester simple. Certaines interactions coûteuses justifient effectivement l’utilisation de mémoïsation spécialisée et une gestion minutieuse des références. La complexité doit être proportionnelle aux avantages mesurés et concentrée autour des points chauds connus, alors qu’ici elle était répartie partout, sur la base de l’hypothèse que n’importe quelle rendu pourrait devenir un point chaud un jour.
La mesure des interactions a changé la décision
Le tournant a été l’analyse des interactions utilisateur complètes à la place du comptage des rendus.
Les actions que les utilisateurs jugeaient lentes ont été enregistrées afin d’analyser où passait le temps. Plusieurs composants étaient affichés fréquemment mais ne consommaient presque aucune ressource. Quelques calculs et des widgets de tiers dominaient, tandis que les comparaisons personnalisées étaient considérées comme de véritables opérations JavaScript. Dans certains cas, la suppression de la mémoisation entraînait un nombre légèrement plus élevé d’affichages, mais un temps total moindre.
Cela semblait incorrect au début, car le modèle mental utilisé considérait moins d’affichages répétés comme un progrès. Le navigateur ne se soucie pas du nombre de fois où les fonctions de composant sont appelées ; il se soucie plutôt de la quantité de travail effectué avant le prochain frame réactif.
À partir de ce moment, les changements étaient évalués en fonction du coût total d’interaction. L’entrée répondait-elle plus rapidement ? Le thread principal était-il libéré ? La mémoire restait-elle stable au cours de sessions longues ? Les versions de production s’amélioraient-elles, et pas seulement celles de développement ? Les correctifs devinrent également locaux : une transformation coûteuse, un grand sous-arbre ou une configuration de bibliothèque instable pouvaient être gérés directement, tandis que le reste de l’application cessait son mécanisme de mémorisation défensive. Le profilage ne permettait pas une refonte ; il empêchait simplement l’équipe de se concentrer sur un chiffre visible qui n’avait aucun rapport avec l’expérience utilisateur.
Ce qui est resté
Tous les memo, useMemo et useCallback n’ont pas été supprimés, car cela reviendrait à reproduire la même erreur en sens inverse.
La mémorisation a été conservée pour les composants coûteux dont les propriétés restaient principalement stables. Les transformations coûteuses et réutilisées lors de rendus fréquents des composants parents ont été mémorisées, de même que les fonctions de rappel sont restées stables là où leur identité était importante pour les abonnements, les intégrations tierces ou les limites de mémorisation pertinentes.
Ce qui a disparu : les caches pour des calculs triviaux, les fonctions de comparaison personnalisées effectuant des vérifications structurelles étendues, ainsi que la réticence à laisser les composants peu coûteux s’afficher. Les états qui changeaient fréquemment ont été rapprochés des interfaces qui les utilisaient, et les valeurs de contexte ayant peu en commun ont été séparées. Le résultat n’était pas le nombre de rendus le plus bas possible. Il s’agissait plutôt d’un modèle de mise à jour plus facile à comprendre, avec des optimisations appliquées là où les preuves le justifiaient. Cela a rendu l’application à la fois plus rapide et plus facile à modifier.
Le rendu n’est pas l’ennemi
Cette histoire a commencé avec un instinct tout à fait raisonnable : observer le fonctionnement d’un système et essayer de l’arrêter. L’erreur a été de considérer chaque rendu comme une perte et chaque rendu sauté comme une victoire.
La mémorisation compare les entrées, conserve des valeurs, suit les dépendances et doit rester correcte au fur et à mesure que le code évolue. Une logique d’égalité personnalisée peut coûter plus cher qu’un simple rendu. Les fonctions de rappel stables peuvent figer un état obsolète. Les résultats en mémoire cache consomment de la mémoire. De plus, un enchevêtrement d’optimisations peut rendre difficile toute modification de l’arbre des composants sans que les utilisateurs ne le remarquent.
Le rendu dans React est souvent peu coûteux. Une faible gestion de l’état, des calculs répétés et coûteux, des abonnements excessifs, des mises à jour provenant de tiers ainsi que le layout du navigateur ont souvent bien plus d’importance. La meilleure solution consiste à laisser les tâches peu coûteuses rester telles : cesser d’encapsuler des composants ordinaires dans des caches et se concentrer sur les rares opérations véritablement coûteuses. L’optimisation doit réduire le coût total d’une interaction, et non simplement le nombre de fois où React appelle une fonction. La vitesse apparaît lorsque la question passe de savoir comment empêcher React de rendre à comprendre pourquoi chaque mise à jour devient coûteuse.
Lectures complémentaires
- Comment le stockage du navigateur corrompt silencieusement les données des utilisateurs et comment y remédier — Les mises à jour perdues, les captures d’écran obsolètes, les écarts de schéma et les fuites sur des appareils partagés peuvent endommager les données localStorage sans aucun erreur. Modèles de gestion de la propriété, de versionnement et de validation.
- Captures d’écran de la mémoire de l’agent, compétences, dégradation et scoping des branches — Quatre améliorations pour une couche mémoire basée sur une base de données vectorielle intégrée destinée aux agents de programmation : des captures d’écran portables avec fonction de fusion, activation de compétences, dégradation avec vérification des conflits, ainsi que une mémoire limitée aux branches.