Neuf schémas courants qui provoquent des re-render inutiles de React
Explique neuf modèles courants d’état et d’effet dans React qui élargissent silencieusement la portée des re-render, ainsi que la manière de réorganiser les composants pour limiter les mises à jour aux seules parties nécessaires.
Sources: 9 React Habits That Quietly Cause Unnecessary Re-Renders
React a tendance à fonctionner rapidement de manière autonome. Les problèmes apparaissent lorsque une petite mise à jour force une partie bien plus importante de l’arbre des composants à se réévaluer que ce que le changement justifie réellement.
Pendant longtemps, il a semblé logique de considérer le rendu excessif comme un problème que React devait résoudre lui-même. Lorsqu’un composant se mettait à jour plus que nécessaire, on avait tendance à l’envelopper dans memo, ou à utiliser useMemo et useCallback. Si l’interface restait lente, on appliquait la même solution aux composants suivants, dans l’espoir que React finirait par se stabiliser.
Ces correctifs permettaient parfois d’améliorer l’aspect des graphiques de profilage, mais ils rendaient rarement la structure des composants sous-jacents plus claire. Les états se trouvaient toujours à un niveau trop élevé dans l’arborescence, les valeurs de contexte restaient regroupées, les données dérivées étaient encore dupliquées, les références restaient instables, et les effets déclenchaient toujours de nouvelles mises à jour dès leur exécution. La mémoisation était en fait utilisée pour protéger l’interface utilisateur d’un flux de données plus complexe que nécessaire.
Un re-render en soi n’est pas une erreur. React est conçu pour appeler à nouveau les fonctions de vos composants chaque fois que leurs entrées pourraient avoir changé, et de nombreux composants sont suffisamment légers pour que bloquer chaque re-render inoffensif ajoute plus de complexité qu’il n’en évite.
La vraie question n’est pas de savoir si un composant a été re-renderisé. C’est pourquoi ce re-render a été déclenché, et quelles opérations ont eu lieu une fois que React l’a invoqué.
La plupart des re-renderings évitables découlent d’habitudes qui semblent inoffensives tant qu’un composant est de petite taille. Leur coût ne se fait sentir que plus tard, une fois que le composant grandit, que ses enfants deviennent plus lourds, ou qu’une valeur en changement rapide commence à invalider une grande partie de la page.
1. Stocker des états en changement fréquent trop haut dans l’arbre
Répartir les états sur un ancêtre commun est l’un des premiers schémas que les développeurs React apprennent. Lorsque deux composants dépendent de la même valeur, en attribuant leur gestion à leur parent commun le plus proche, on les maintient synchronisés et on évite des sources de vérité dupliquées.
Cela devient un problème lorsque chaque petit état d’interaction locale est transféré au composant au niveau de la page, simplement parce que ce dernier a accès à tout.
Une boîte de recherche conserve sa valeur de texte sur le tableau de bord. Le statut ouvert/fermé d’un menu déroulant est indiqué à côté des données du tableau. L’état de survol est suivi par la grille de cartes. Les champs de formulaire internes d’un modal sont gérés par l’écran qui le déclenche. Chaque frappe ou interaction mineure force désormais ce propriétaire de niveau supérieur à re-renderiser l’affichage.
Puisque le composant de page renvoie constamment ses enfants mis à jour, modifier ce texte de recherche peut provoquer la réexécution des graphiques, des tableaux, du système de navigation et des dialogues, même si aucune de leurs données réelles n’a changé.
Ce n’est pas non plus le cas de cacher chaque élément d’état aussi profond que possible. L’état doit se trouver à la plus petite frontière qui a encore besoin de coordonner les composants concernés. Un filtre dont dépendent à la fois l’URL, le tableau et un bouton d’exportation devrait probablement être placé au-dessus de ces régions. Le fait qu’une seule aide contextuelle soit actuellement ouverte n’a généralement pas besoin d’y être stockée.
Cette distinction est particulièrement importante pour les valeurs qui se mettent à jour rapidement. La saisie, le glisser-déposer, le suivi du curseur, l’avancement des animations et les champs de formulaire en temps réel peuvent générer de nombreuses mises à jour par seconde. En plaçant l’un d’eux au-dessus d’un sous-arbre gourmand, une interaction purement locale se transforme en une tâche pour toute la page.
Rapprocher l’état des composants qui l’utilisent réellement a tendance à améliorer les performances de manière plus directe que d’envelopper toute la page dans une mémoisation, car React n’a pas de parent à invalider à nouveau une fois que ce dernier ne possède plus l’état en question.
2. Utiliser un seul contexte pour des valeurs non liées
Le contexte existe pour résoudre un problème spécifique : permettre aux composants descendants de lire des valeurs partagées sans forcer chaque composant intermédiaire à transmettre des propriétés qu’il n’utilise jamais.
Les problèmes commencent lorsque un seul fournisseur devient le conteneur universel pour tout ce qui est considéré comme "global"
Il est courant de voir un seul contexte à l’échelle de l’application qui contient l’utilisateur actuel, le thème, le nombre de notifications non lues, la visibilité de la barre latérale, la région linguistique, les flags fonctionnels, les permissions, ainsi que quelques fonctions d’action. Cela semble ordonné — un fournisseur, un Hook personnalisé pour l’utiliser.
Mais du point de vue des mises à jour, ces valeurs n’ont pas de responsabilité commune. Le nombre de notifications peut être mis à jour plusieurs fois par minute ; le thème peut rester inchangé pendant des mois. La visibilité de la barre latérale correspond à l’état local de l’interface, tandis que l’utilisateur authentifié reflète la session elle-même. Les regrouper signifie que chaque consommateur s’abonne en fait aux modifications qui ne l’intéressent pas du tout.
Souvent, le fournisseur reconstruit également sa valeur de contexte à chaque rendu :
function AppProvider({ children }) {
const [theme, setTheme] = useState("light");
const [notifications, setNotifications] = useState([]);
const [sidebarOpen, setSidebarOpen] = useState(false);
const value = {
theme,
setTheme,
notifications,
setNotifications,
sidebarOpen,
setSidebarOpen,
};
return (
<AppContext.Provider value={value}>
{children}
</AppContext.Provider>
);
}
Chaque fois que le fournisseur rérendu, value devient un objet entièrement nouveau. Les consommateurs perçoivent cela comme une modification par référence, même si le champ spécifique qu’ils lisent n’a pas réellement changé.
Mémoriser la valeur transmise au fournisseur peut empêcher de nouvelles rendus dus à une mise à jour d’un élément parent non lié, mais il n’est pas possible de diviser la valeur du contexte en parties indépendantes. Si les notifications changent, l’ensemble de l’objet combiné est toujours considéré comme modifié.
Une approche plus efficace consiste à diviser le contexte en fonction des lignes de responsabilité et de la fréquence des mises à jour, plutôt que pour des raisons de simplicité liées à l’existence d’un seul fichier de fournisseur. Les configurations qui changent rarement peuvent être stockées dans un contexte unique, tandis que les données en temps réel qui évoluent rapidement peuvent avoir leur propre contexte. L’état de mise en page purement local n’a pas nécessairement besoin de passer par le contexte global de l’application.
Le contexte n’est pas seulement un mécanisme de transport pour les valeurs — c’est aussi un moyen de distribuer les mises à jour. La manière dont la valeur du fournisseur est structurée détermine jusqu’où chaque modification peut se propager.
3. Réconstruction d’objets, de tableaux et de fonctions lorsque l’identité est vraiment importante
Créer une valeur nouvelle à chaque rendu est un comportement normal de JavaScript. Un objet inline, un tableau filtré ou une nouvelle fonction flèche coûtent généralement si peu d’efforts que tenter de l’éviter ne vous apportera aucun avantage.
Les choses deviennent complexes lorsque certaines logiques ultérieures dépendent de l’identité de cette valeur plutôt que seulement de son contenu.
Un enfant mémorisé vérifie ses props par référence. Un Effect vérifie sa liste de dépendances par référence. Un composant de tableau peut reconstruire ses structures internes chaque fois que la référence du tableau des colonnes change. Un fournisseur de contexte informe les abonnés d’une mise à jour chaque fois que la valeur qu’il contient fait référence à une nouvelle identité.
Deux objets peuvent contenir des données identiques tout en restant deux objets distincts en mémoire. Si un enfant reçoit un objet de paramètres entièrement nouveau à chaque rendu du parent, il n’a aucun moyen de savoir que rien dans ces paramètres n’a réellement changé.
Une réaction courante consiste à envelopper par défaut chaque objet dans useMemo et chaque fonction dans useCallback. Cela ne fait que remplacer une habitude réflexe par une autre. La mémorisation n’est pas gratuite : elle comporte ses propres coûts d’overhead, son propre tableau de dépendances à gérer, ainsi que ses propres frais d’entretien. Elle ne s’avère utile que lorsque une référence stable permet d’éviter ou de réutiliser des opérations coûteuses.
Les valeurs qui ne changent jamais peuvent souvent être définies complètement en dehors du composant. Au lieu d’emballer plusieurs propriétés dans un seul objet uniquement pour plus de clarté, un enfant peut souvent recevoir directement les valeurs primitives individuelles. Une fonction ne doit être stabilisée que lorsqu’elle alimente un Effect, une abonnement ou une frontière de mémorisation où l’identité influence réellement le comportement.
Le véritable test réside dans l’observation de ce qui se passe suite à l’introduction de cette nouvelle référence. Si rien en aval ne s’en soucie, recréer la valeur à chaque rendu n’a aucun effet néfaste. Mais si un objet nouveau provoque la reconstruction d’une grande table ou entraîne la dissolution puis la réconnexion d’une abonnement, l’identité de cet objet fait alors partie des exigences de performance du composant et doit être traitée en conséquence.
4. Stocker des valeurs calculées et les maintenir à jour via des effets
L’une des sources les moins évidentes de rendus supplémentaires est le fait de conserver en mémoire un état pour quelque chose qui pourrait simplement être calculé à partir des props ou de l’état existants.
Considérez un composant qui contient la liste complète des enregistrements, le texte de filtrage actuel, ainsi qu’une liste filtrée stockée séparément. Ou encore un formulaire qui enregistre ses champs, ses erreurs de validation, ainsi qu’un indicateur canSubmit distinct. Ou un composant de liste qui stocke à la fois un identifiant sélectionné et une deuxième valeur d’état contenant l’objet correspondant.
Puisque cette valeur dupliquée peut devenir décalée, les développeurs ajoutent un Effect pour la maintenir à jour :
function Products({ products }) {
const [search, setSearch] = useState("");
const [visibleProducts, setVisibleProducts] = useState(products);
useEffect(() => {
setVisibleProducts(
products.filter((product) =>
product.name.toLowerCase().includes(search.toLowerCase())
)
);
}, [products, search]);
// ...
}
C’est ce qui se passe lorsque search change : le composant s’affiche d’abord avec la liste filtrée obsolète toujours dans l’état, React enregistre cette affichage, puis l’Effect est déclenché, met à jour l’état et planifie un nouvel affichage.
Cet second affichage n’existe que parce qu’un résultat calculé a été stocké comme s’il s’agissait d’une partie d’état indépendante.
Si visibleProducts est calculé directement lors du rendu, il reste automatiquement synchronisé et le rendu supplémentaire disparaît complètement. Lorsque la logique de filtrage est coûteuse, vous pouvez envelopper ce calcul dans une mémoire tampon basée sur ses entrées réelles. Ce qui importe, c’est que ce type de mémoïsation ne cache qu’un calcul — elle ne doit jamais transformer le résultat stocké en une seconde source de vérité pouvant être modifiée indépendamment.
Ce schéma a des conséquences allant au-delà des rendus inutiles. L’état dérivé peut afficher temporairement une valeur contradictoire par rapport à ses entrées, et l’empilement de plusieurs effets peut entraîner des rendus répétés en chaîne. L’élimination de l’état redondant supprime généralement plusieurs effets de synchronisation en même temps.
5. S’abonner à plus d’un stock externe que ce dont on a réellement besoin
Les bibliothèques d’état gérées par des sélecteurs permettent de fournir des mises à jour très ciblées, mais uniquement si les composants sélectionnent réellement des valeurs étroites et spécifiques.
Une astuce fréquente consiste à s’abonner à tout un stock d’état, ou à un grand objet au niveau des fonctionnalités, puis à ne récupérer qu’un seul champ pour l’utiliser. Cela signifie que le composant se rérendre à nouveau chaque fois qu’un élément à l’intérieur de cet objet plus large change — même des champs qu’il n’a jamais touchés.
Ce problème n’est pas propre aux bibliothèques de gestion d’état. Il se manifeste de la même manière avec les caches de récupération de données, les systèmes basés sur des observables et les hooks d’abonnement personnalisés. Lire dans une source volumineuse uniquement par commodité rend un composant réactif à bien plus de changements qu’il ne le devrait.
La quantité d’état à laquelle un composant est techniquement autorisé d’accéder n’est pas la même question que celle de savoir quelle partie de cet état devrait déclencher sa rérendre.
6. Clés instables qui provoquent silencieusement des remontages au lieu de mises à jour
Un re-render exécute à nouveau un composant tout en conservant son identité, son état local et sa position inchangés. Un remontage fait quelque chose de différent : il détruit l’instance du composant existante et en crée une nouvelle à partir de zéro. Le remontage est généralement beaucoup plus coûteux, car React ne peut plus réutiliser l’état local précédent, les relations DOM existantes ou tout ressource liée au cycle de vie du composant.
Des clés qui ne sont pas stables peuvent transformer ce qui devrait être de simples mises à jour en remontages répétés.
Si une clé est dérivée d’une valeur aléatoire, de l’heure actuelle ou d’un champ qui change pendant que l’élément est modifié, React interprète cela comme la disparition complète de l’ancien élément. Comme chaque re-render génère une identité différente, l’enfant précédent est supprimé et un tout nouveau élément est monté à sa place.
Souvent, les symptômes visibles apparaissent avant même que quiconque ne remarque un problème de performance. Les champs de texte perdent leur focus au milieu de la saisie, les lignes dépliées se referment automatiquement, les animations reprennent depuis le début, et l’état des composants locaux disparaît. L’impact sur les performances devient sérieux lorsque les composants réinstallés nécessitent des tâches de configuration plus lourdes — comme l’initialisation d’éditeurs, de graphiques, d’observateurs, de abonnements, de lecteurs multimédias ou de requêtes réseau.
L’utilisation d’indices d’array comme clés crée une version plus subtile de ce problème lorsque les éléments de liste peuvent être réordonnés, insérés ou supprimés. React peut conserver l’état du composant lié à une position dans la liste, même si un autre élément occupe désormais cette position. Ce décalage peut faire en sorte que l’état soit associé au mauvais élément et entraîner des tâches de réconciliation inutiles, tout simplement parce que les clés ne reflètent pas réellement des identités stables des éléments.
7. Laisser des interfaces lourdes montées lorsqu’elles sont hors de vue
Les onglets, les tiroirs, les modaux et les panneaux rétractables sont souvent laissés montés mais visuellement cachés grâce au CSS, car cette approche préserve l’état interne et permet à l’interface de se rouvrir instantanément. Pour du contenu léger, cet équilibre est souvent raisonnable.
Cependant, pour des interfaces plus volumineuses, le fait de rester monté signifie qu’elles peuvent continuer à écouter le contexte et les sources externes, à recevoir de nouvelles propriétés, à se rérender en permanence, à effectuer des calculs et à maintenir leurs effets actifs — tout en ce que l’utilisateur ne peut voir aucun résultat. Un tableau de bord peut conserver plusieurs graphiques entièrement actifs derrière des onglets que personne ne consulte actuellement. Un éditeur caché dans un panneau peut continuer à traiter les mises à jour de documents en arrière-plan. Un dialogue qui a été fermé visuellement peut encore conserver un grand arbre de formulaires ainsi que sa logique de validation.
Votre application en paie le prix réel, car React continue de considérer ce sous-arbre monté comme faisant partie des chemins d’affichage pertinents. Définir display: none ne modifie que la manière dont le navigateur dispose les éléments — cela n’a aucun impact sur le fait que React continue de gérer et de mettre à jour ce sous-arbre.
Néanmoins, désmonter chaque élément de l’interface caché n’est pas non plus automatiquement la bonne solution. Reconstruire un éditeur lourd à partir de zéro chaque fois que quelqu’un change de onglet peut ralentir l’ensemble du processus, et cela peut effacer des états utilisateur qui devraient persister. Ce qui importe, c’est de prendre une décision délibérée en fonction du cycle de vie du composant et de son coût réel.
Une option consiste à différer le montage d’un panneau lourd jusqu’à son activation initiale. Ceux qui doivent réellement survivre à un démontage visuel peuvent être conservés dans un conteneur plus petit dédié, tandis que l’arbre rendu lui-même disparaît. Certains produits trouvent un équilibre en ne gardant montés que les panneaux les plus récemment utilisés et en libérant le reste.
La vraie question n’est pas de savoir si quelque chose est actuellement à l’écran. C’est plutôt de déterminer s’il doit continuer à recevoir et à réagir aux mises à jour lorsqu’il est hors écran.
8. Permettre à les effets de se redémarrer en raison de dépendances instables
Un effet qui dépend d’une certaine valeur se nettoiera et se relancera chaque fois que cette valeur change — ce qui est tout à fait correct lorsque la relation externe représentée par l’effet a réellement changé.
Le problème, c’est que des dépendances instables peuvent donner l’impression que cette relation a changé à chaque rendu, même lorsque rien de significatif ne s’est produit.
Prenons un composant qui crée un objet d’options et le transmet à un Hook personnalisé, lequel utilise ensuite cet objet comme dépendance pour un Effect. Comme le composant parent crée un objet entièrement nouveau à chaque rendu, l’Effect continue de déconnecter et de reconnecter sa connexion. De même, un gestionnaire d’événements recréé à chaque rendu et utilisé comme dépendance pour un effet d’écoute fera en sorte que cet écouteur soit supprimé et réattaché encore et encore. Un objet de configuration de requête peut déclencher une nouvelle demande à chaque fois, même si aucun de ses champs significatifs n’a réellement changé.
Ce type de surcharge ne se manifeste pas toujours sous forme de problème de rendu. Au contraire, il apparaît sous forme de demandes réseau dupliquées, d’ouvertures et de fermetures rapides de connexions, d’une interface utilisateur qui clignote, ou encore de gestionnaires d’événements qui disparaissent puis réapparaissent. Pire encore, ces redémarrages d’Effect appellent souvent eux-mêmes des fonctions de mise à jour d’état, déclenchant ainsi de nouveaux rendus qui masquent la cause originale du problème.
La solution n’est pas de supprimer les dépendances de l’array juste pour faire taire les avertissements. Les dépendances existent pour décrire précisément quels valeurs l’Effect lit, et en cacher une entraîne des closures obsolètes ainsi que des problèmes de synchronisation.
La meilleure solution consiste à donner à la dépendance une identité stable lorsque c’est bien cette identité qui compte réellement. Selon la situation, vous pouvez construire l’objet directement à l’intérieur de l’Effect à partir de valeurs primitives, stabiliser la fonction de rappel afin qu’elle ne soit pas reconstruite à chaque rendu, ou réaliser que cette logique n’appartient même pas à un Effect et la déplacer plutôt dans un gestionnaire d’événements.
Un Effect doit être redémarré parce que le système externe exige véritablement une nouvelle synchronisation — et non parce qu’un rendu a produit par hasard un autre objet fonctionnellement identique.
9. Remplacer l’état par un nouvel objet représentant la même chose
Selon la manière dont React compare les valeurs, il peut parfois omettre de rérender lorsque l’état suivant est identique à l’actuel. Mais pour les objets et les tableaux, fournir à React une référence fraîche fait en sorte que l’état semble différent, même si son contenu réel indique la même chose.
Cette approche apparaît fréquemment dans les gestionnaires d’événements et la logique de stockage. Un filtre est réinitialisé avec un nouvel objet qui est en réalité équivalent à celui qui était déjà présent. Un réducteur renvoie un objet d’état nouvellement créé pour une action qui n’a rien changé. Une réponse obtenue via des requêtes périodiques est copiée dans un nouveau tableau, même si chaque élément à l’intérieur reste inchangé.
React ne dispose d’aucun moyen général pour détecter par lui-même une égalité sémantique profonde. Si celui qui gère l’état renvoie un nouvel objet, React le considère comme une mise à jour suffisamment significative pour être traitée.
Afin d’éviter cela, les réducteurs doivent retourner l’objet d’état existant lorsque une action ne modifie en réalité rien. Les fonctions de mise à jour de l’état peuvent comparer les valeurs importantes avant de remplacer un objet structuré entier. Les couches chargées de normaliser les données reçues peuvent conserver les mêmes références pour les enregistrements qui n’ont pas changé, au lieu de reconstruire toute la collection à partir de zéro après chaque réponse.
Tout cela ne signifie pas pour autant qu’il faille effectuer une comparaison approfondie à chaque mise à jour de l’état. Une telle comparaison peut finir par coûter plus cher que le rendu qu’elle cherche à éviter, et elle introduit une nouvelle notion d’« égalité » qu’il faut maintenant maintenir de manière cohérente. Une approche plus durable consiste à préserver naturellement l’identité des objets chaque fois que les données sous-jacentes n’ont pas réellement changé.
Le partage structurel permet à React, ainsi qu’à tous les consommateurs mémorisés en aval, de distinguer un véritable changement d’une copie répétée des mêmes informations. Une nouvelle référence doit indiquer un nouvel état — et non simplement une nouvelle allocation de mémoire contenant les mêmes valeurs.
L’objectif n’est pas d’éliminer les re-renderings
Les applications React ne deviennent pas rapides parce qu’elles refusent de se rendre. Elles deviennent rapides parce que les rendus suivent les changements significatifs et se limitent aux parties de l’arbre qui ont réellement besoin de reconsidérer leur sortie.
Re-renderiser un composant petit et simple est souvent moins coûteux que de créer et de maintenir un système complexe de mémorisation autour de lui. Une mise à jour de contexte qui ne concerne que quelques consommateurs légers peut ne justifier absolument aucune modification architecturale. Les décisions relatives à la performance doivent être guidées par le coût réel de l’interaction, par des analyses de performance, et par l’échelle réellement attendue — et non par un instinct général de prévenir tout rendu chaque fois que c’est possible.
La mémorisation peut vraiment être utile une fois que ces relations fondamentales ont été correctement comprises. Elle fonctionne le mieux lorsque les limites des composants, la propriété de l’état et les identités de référence ont déjà un sens en soi. Sans cette base, la mémorisation ne devient qu’une couche de défense entourant un graphe d’actualisations qui reste inutilement volumineux.
La question la plus utile à se poser concernant les performances de React n’est pas « comment empêcher ce composant de s’afficher ? »
C’est plutôt « qu’a vraiment changé, qui est responsable de ce changement, et pourquoi ce composant a-t-il été demandé de se réévaluer à nouveau ? »
Lorsque vous avez des réponses claires à ces questions, une grande partie des redessins inutiles disparaît d’elle-même — sans transformer l’arbre de composants en un enchevêtrement de caches et de fonctions de comparaison.