Maintenant que nous connaissons les modèles de composition les plus importants et leur fonctionnement, il est temps d'approfondir la question des performances.
Plus précisément, parlons d'un sujet fortement associé à l'amélioration des performances dans React, mais qui, en réalité, ne fonctionne pas comme prévu dans au moins la moitié des cas où nous l'utilisons.
La memoization. Nos hooks favoris useMemo et useCallback et le composant d'ordre supérieur React.memo.
Et je ne plaisante pas ni n'exagère en parlant de la moitié du temps. Faire une memoization correctement est difficile, beaucoup plus difficile qu'il n'y paraît. À la fin de ce chapitre, j'espère que vous serez d'accord avec moi.
Voici ce que vous allez apprendre :
- Quel est le problème que nous essayons de résoudre avec la memoization (et ce n'est pas la performance en soi !).
- Comment useMemo et useCallback fonctionnent en interne, et quelle est la différence entre eux.
- Pourquoi la memoization des props sur un composant en lui-même est un anti-pattern.
- Ce qu'est React.memo, pourquoi nous en avons besoin, et quelles sont les règles de base pour l'utiliser avec succès.
- Comment l'utiliser correctement avec le pattern "elements as children".
- Quel est le rôle de useMemo dans les calculs coûteux.
Le problème : la comparaison des valeurs#
Tout tourne autour de la comparaison des valeurs en JavaScript. Les valeurs primitives comme les chaînes de caractères ou les booléens sont comparées par leur valeur réelle :
const a = 1;
const b = 1;
a === b; // sera true, les valeurs sont exactement les mêmes
Avec les objets et tout ce qui hérite des objets (comme les tableaux ou les fonctions), c'est une autre histoire.
Lorsque nous créons une variable avec un objet const a = { id: 1 }, la valeur stockée n'est pas la valeur réelle. C'est juste une référence à une partie de la mémoire qui contient cet objet. Lorsque nous créons une autre variable avec les mêmes données const b = { id: 1 }, elle sera stockée dans une autre partie de la mémoire. Et comme il s'agit d'une partie différente, la référence sera également différente.
Donc, même si ces objets semblent exactement identiques, les valeurs dans nos nouvelles variables a et b sont différentes : elles pointent vers des objets différents en mémoire. Par conséquent, une simple comparaison entre elles renverra toujours false :
const a = { id: 1 };
const b = { id: 1 };
a === b; // sera toujours false
Pour que la comparaison a === b renvoie true, nous devons nous assurer que la référence dans b est exactement la même que la référence dans a. Comme ceci :
const a = { id: 1 };
const b = a;
a === b; // sera true
C'est ce à quoi React doit faire face chaque fois qu'il doit comparer des valeurs entre les re-renders. Il effectue cette comparaison chaque fois que nous utilisons des hooks avec des dépendances, comme dans useEffect par exemple :
const Component = () => {
const submit = () => {};
useEffect(() => {
// appelle la fonction ici
submit();
// elle est déclarée en dehors du useEffect
// donc elle doit être dans les dépendances
}, [submit]);
return ...
}
Dans cet exemple, la fonction submit est déclarée en dehors du hook useEffect. Donc si je veux l'utiliser à l'intérieur du hook, elle doit être déclarée comme une dépendance. Mais comme submit est déclarée localement à l'intérieur de Component, elle sera recréée à chaque re-render de Component. Rappelez-vous que nous avons discuté dans le Chapitre 2 - Elements, children as props, et re-renders - un re-render n'est rien d'autre que React appelant les fonctions du composant. Chaque variable locale sera recréée pendant ce processus, exactement comme n'importe quelle fonction en JavaScript.
Ainsi, React comparera submit avant et après le re-render afin de déterminer s'il doit exécuter le hook useEffect cette fois-ci. La comparaison renverra toujours false puisque c'est une nouvelle référence à chaque fois. Par conséquent, le hook useEffect sera déclenché à chaque re-render.
useMemo et useCallback : comment ils fonctionnent#
Pour combattre ce problème, nous avons besoin d'un moyen de préserver la référence à la fonction submit entre les re-renders. De cette façon, la comparaison renvoie true et le hook n'est pas déclenché inutilement. C'est là qu'interviennent les hooks useMemo et useCallback. Les deux ont une API similaire et servent un objectif similaire : s'assurer que la référence dans la variable à laquelle ces hooks sont assignés ne change que lorsque la dépendance du hook change.
Si j'enveloppe ce submit dans useCallback :
const submit = useCallback(() => {
// pas de dépendances, la référence ne changera pas entre les re-renders
}, []);
}
alors la valeur dans la variable submit sera la même référence entre les re-renders, la comparaison renverra true, et le hook useEffect qui en dépend ne sera pas déclenché à chaque fois :
const Component = () => {
const submit = useCallback(() => {
// soumettre quelque chose ici
}, [])
useEffect(() => {
submit();
// submit est mémorisé, donc useEffect ne sera pas déclenché à chaque re-render
}, [submit]);
return ...
}
C'est exactement la même chose avec useMemo, sauf que dans ce cas, je dois retourner la fonction que je veux mémoriser :
let cachedResult;
const func = (callback) => {
if (dependenciesEqual()) {
return cachedResult;
}
cachedResult = callback();
return cachedResult;
};
L'implémentation réelle est légèrement plus complexe, bien sûr, mais c'est l'idée de base.
Pourquoi tout cela est-il important ? Pour les applications réelles, ça ne l'est pas, à part pour comprendre la différence dans l'API. Cependant, il existe cette croyance qui apparaît ici et là que useMemo est meilleur pour les performances que useCallback, puisque useCallback recrée la fonction qui lui est passée à chaque re-render, et que useMemo ne le fait pas. Comme vous pouvez le voir, ce n'est pas vrai. La fonction en premier argument sera recréée pour les deux.
Le seul cas où cela pourrait théoriquement avoir de l'importance, c'est lorsque nous passons en premier argument non pas la fonction elle-même, mais le résultat d'une autre exécution de fonction codée en dur. En gros, ceci :
const submit = useCallback(something(), []);
Dans ce cas, la fonction something sera appelée à chaque re-render, même si la référence submit ne changera pas. Évitez donc de faire des calculs coûteux dans ces fonctions.
Anti-pattern : mémoriser les props#
Le deuxième cas d'utilisation le plus populaire pour les hooks de mémorisation, après les valeurs mémorisées comme dépendances, est leur passage aux props. Vous avez sûrement vu du code comme celui-ci :
const Component = () => {
const onClick = useCallback(() => {
// faire quelque chose au clic
}, []);
return <button onClick={onClick}>click me</button>;
};
Malheureusement, ce useCallback ici est tout simplement inutile. Il existe cette croyance répandue, que même ChatGPT semble partager, selon laquelle la mémorisation des props empêche les composants de se re-render. Mais comme nous le savons déjà des chapitres précédents, si un composant se re-render, chaque composant à l'intérieur de ce composant se re-render également.
Donc, que nous enveloppions notre fonction onClick dans useCallback ou non n'a aucune importance ici. Tout ce que nous avons fait, c'est faire faire un peu plus de travail à React et rendre notre code un peu plus difficile à lire. Quand il n'y a qu'un seul useCallback, ça n'a pas l'air si mal. Mais ce n'est jamais un seul, n'est-ce pas ? Il y en aura un autre, puis un autre, ils commenceront à dépendre les uns des autres, et avant que vous ne le sachiez, la logique de l'application est simplement enterrée sous un désordre incompréhensible et indébogable de useMemo et useCallback.
Il n'y a que deux cas d'utilisation majeurs où nous avons réellement besoin de mémoriser les props sur un composant. Le premier est lorsque cette prop est utilisée comme dépendance dans un autre hook dans le composant en aval.
const Parent = () => {
// ceci doit être mémorisé !
// Child l'utilise dans useEffect
const fetch = () => {};
return <Child onMount={fetch} />;
};
const Child = ({ onMount }) => {
useEffect(() => {
onMount();
}, [onMount]);
};
Cela devrait être évident : si une valeur non primitive va dans une dépendance, elle doit avoir une référence stable entre les re-renders, même si elle provient d'une chaîne de props.
Et le deuxième cas est lorsqu'un composant est enveloppé dans React.memo
Qu'est-ce que React.memo#
React.memo ou simplement memo est un utilitaire très utile que React nous fournit. Il nous permet de mémoriser le composant lui-même. Si le re-render d'un composant est déclenché par son parent (et seulement dans ce cas), et si ce composant est enveloppé dans React.memo, alors et seulement alors React s'arrêtera et vérifiera ses props. Si aucune des props ne change, alors le composant ne sera pas re-render, et la chaîne normale des re-renders sera arrêtée.

C'est encore une fois le cas où React effectue cette comparaison dont nous avons parlé au début du chapitre. Si ne serait-ce qu'une seule des props a changé, alors le composant enveloppé dans React.memo sera re-render comme d'habitude :
const Child = ({ data, onChange }) => {};
const ChildMemo = React.memo(Child);
const Component = () => {
// objet et fonction déclarés en ligne
// changeront à chaque re-render
return <ChildMemo data={{ ...some_object }} onChange={() => {...}} />
}
Et dans le cas de l'exemple ci-dessus, data et onChange sont déclarés en ligne, donc ils changeront à chaque re-render.
C'est là que useMemo et useCallback brillent :
const Child = ({ data, onChange }) => {};
const ChildMemo = React.memo(Child);
const Component = () => {
const data = useMemo(() => ({ ... }), []); // un objet
const onChange = useCallback(() => {}, []); // un callback
// data et onChange ont maintenant une référence stable
// les re-renders de ChildMemo seront évités
return <ChildMemo data={data} onChange={onChange} />
}
En mémorisant data et onChange, nous préservons la référence à ces objets entre les re-renders. Maintenant, lorsque React compare les props sur le composant ChildMemo, la vérification passera, et le composant ne se re-render pas.
Mais s'assurer que toutes les props sont mémorisées n'est pas aussi simple qu'il n'y paraît. Nous le faisons mal dans tellement de cas ! Et une seule erreur conduit à une vérification des props cassée, et en conséquence - chaque React.memo, useCallback, et useMemo devient complètement inutile.
React.memo et les props issues des props#
Le premier et le plus simple cas de mémorisation cassée concerne les props qui sont passées depuis d'autres props. Particulièrement lorsque l'étalement (spreading) des props dans les composants intermédiaires est impliqué. Imaginez que vous ayez une chaîne de composants comme celle-ci :
const Child = () => {};
const ChildMemo = React.memo(Child);
const Component = (props) => {
return <ChildMemo {...props} />;
};
const ComponentInBetween = (props) => {
return <Component {...props} />;
};
const InitialComponent = (props) => {
// celui-ci aura un state et déclenchera le re-render de Component
return (
<ComponentInBetween {...props} data={{ id: '1' }} />
);
};
Quelle est la probabilité, selon vous, que ceux qui doivent ajouter ces données supplémentaires à l'InitialComponent examinent chaque composant à l'intérieur, de plus en plus profondément, pour vérifier si l'un d'entre eux est enveloppé dans React.memo ? Surtout si tous ces composants sont répartis dans différents fichiers et sont assez complexes dans leur implémentation. Cela n'arrivera jamais.
Mais en conséquence, l'InitialComponent casse la mémorisation du composant ChildMemo puisqu'il lui passe une prop data non mémorisée.
Donc, à moins que vous ne soyez préparé et capable d'imposer la règle selon laquelle chaque prop partout doit être mémorisée, l'utilisation de la fonction React.memo sur les composants doit suivre certaines règles.
Règle 1 : ne jamais étaler (spread) les props qui viennent d'autres composants.#
Au lieu de ceci :
const Component = (props) => {
return <ChildMemo {...props} />;
};
il faut faire quelque chose d'explicite comme ceci :
const Component = (props) => {
return <ChildMemo some={prop.some} other={props.other} />;
};
Règle 2 : éviter de passer des props non primitives qui viennent d'autres composants.#
Même l'exemple explicite comme celui ci-dessus est encore assez fragile. Si l'une de ces props est un objet ou une fonction non mémorisée, la mémorisation se cassera à nouveau.
Règle 3 : éviter de passer des valeurs non primitives qui viennent de custom hooks.#
Cela semble presque contradictoire avec la pratique généralement acceptée d'extraire la logique avec état dans des custom hooks. Mais leur commodité est une arme à double tranchant ici : ils cachent certainement les complexités, mais cachent également si les données ou les fonctions ont des références stables. Considérez ceci :
const Component = () => {
const { submit } = useForm();
return <ChildMemo onChange={submit} />;
};
La fonction submit est cachée dans le custom hook useForm. Et chaque custom hook sera déclenché à chaque re-render. Pouvez-vous dire à partir du code ci-dessus s'il est sûr de passer ce submit à notre ChildMemo ?
Non, vous ne pouvez pas. Et il y a des chances que cela ressemble à ceci :
const useForm = () => {
// beaucoup de code pour contrôler l'état du formulaire
const submit = () => {
// faire quelque chose lors de la soumission, comme la validation des données
};
return {
submit,
};
};
En passant cette fonction submit à notre ChildMemo, nous venons de casser sa mémorisation - désormais, il se re-render comme s'il n'était pas enveloppé dans React.memo.
Vous voyez à quel point ce pattern est déjà fragile ? Ça empire.
React.memo et les children#
Regardons ce code :
const ChildMemo = React.memo(Child);
const Component = () => {
return (
<ChildMemo>
<div>Du texte ici</div>
</ChildMemo>
);
};
Cela semble assez innocent : un composant mémorisé sans props, qui rend une div à l'intérieur, n'est-ce pas ? Eh bien, la mémorisation est cassée ici aussi, et l'enveloppe React.memo est complètement inutile.
Rappelez-vous ce dont nous avons discuté dans le Chapitre 2 - Elements, children as props, et re-renders ? Cette belle syntaxe d'imbrication n'est rien d'autre qu'un sucre syntaxique pour la prop children. Je peux simplement réécrire ce code comme ceci :
const Component = () => {
const content = useMemo(
() => <div>Du texte ici</div>,
[],
);
return <ChildMemo>{content}</ChildMemo>;
};
et cela se comportera exactement de la même manière. Et comme nous l'avons vu dans le Chapitre 2 - Elements, children as props, et re-renders, tout ce qui est JSX n'est qu'un sucre syntaxique pour React.createElement et n'est en réalité qu'un objet. Dans ce cas, ce sera un objet avec le type "div" :
{
type: "div",
... // le reste des propriétés
}
Donc ce que nous avons ici, du point de vue de la mémorisation et des props, c'est un composant qui est enveloppé dans React.memo et qui a une prop avec un objet non mémorisé !
Pour le corriger, nous devons également mémoriser la div :
const Component = () => {
const content = useMemo(
() => <div>Du texte ici</div>,
[],
);
return <ChildMemo>{content}</ChildMemo>;
};
ou, en revenant à la jolie syntaxe :
const Component = () => {
const content = useMemo(
() => <div>Du texte ici</div>,
[],
);
return <ChildMemo>{content}</ChildMemo>;
};
La même histoire s'applique aux children en tant que render prop, d'ailleurs. Ceci sera cassé :
const Component = () => {
return (
<ChildMemo>{() => <div>Du texte ici</div>}</ChildMemo>
);
};
Notre children ici est une fonction qui est recréée à chaque re-render. Il faut aussi la mémoriser avec useMemo :
const Component = () => {
const content = useMemo(
() => () => <div>Du texte ici</div>,
[],
);
return <ChildMemo>{content}</ChildMemo>;
};
Ou simplement utiliser useCallback :
const Component = () => {
const content = useCallback(
() => <div>Du texte ici</div>,
[],
);
return <ChildMemo>{content}</ChildMemo>;
};
Jetez un coup d'œil à votre application maintenant. Combien de ces cas ont-ils échappé à votre attention ?
React.memo et les children mémorisés (presque)#
Si vous avez parcouru votre application, corrigé tous ces patterns, et que vous vous sentez confiant que la mémorisation est dans un bon état maintenant, ne vous précipitez pas. Quand la vie a-t-elle déjà été si simple ! Que pensez-vous de celui-ci ? Est-il correct ou cassé ?
const ChildMemo = React.memo(Child);
const ParentMemo = React.memo(Parent);
const Component = () => {
return (
<ParentMemo>
<ChildMemo />
</ParentMemo>
);
};
Les deux sont mémorisés, donc ça doit être bon, n'est-ce pas ? Faux. ParentMemo se comportera comme s'il n'était pas enveloppé dans React.memo - ses children ne sont en réalité pas mémorisés ! Regardons de plus près ce qui se passe. Comme nous le savons déjà,
Les Elements ne sont qu'un sucre syntaxique pour React.createElement, qui retourne un objet avec le type qui pointe vers le composant. Si je créais un Element <Parent />, ce serait ceci :
{
type: Parent,
... // le reste des propriétés
}
Avec les composants mémorisés, c'est exactement la même chose. L'élément <ParentMemo/> sera converti en un objet de forme similaire. Seule la propriété "type" contiendra des informations sur notre ParentMemo.
Et cet objet n'est qu'un objet, il n'est pas mémorisé par lui-même. Donc encore une fois, du point de vue de la mémorisation et des props, nous avons un composant ParentMemo qui a une prop children qui contient un objet non mémorisé. D'où la mémorisation cassée sur ParentMemo.
Pour le corriger, nous devons mémoriser l'objet lui-même :
const Component = () => {
const child = useMemo(() => <ChildMemo />, []);
return <ParentMemo>{child}</ParentMemo>;
};
Et alors nous n'aurons peut-être même plus besoin du ChildMemo du tout. Cela dépend de son contenu et de nos intentions, bien sûr. Au moins dans le but d'empêcher ParentMemo de se re-render, ChildMemo est inutile, et il peut redevenir un simple Child
const Component = () => {
const child = useMemo(() => <Child />, []);
return <ParentMemo>{child}</ParentMemo>;
};
useMemo et les calculs coûteux#
Et le dernier cas d'utilisation assez populaire lié aux performances pour useMemo est la mémorisation des "calculs coûteux". Entre guillemets, car il est également souvent mal utilisé.
Tout d'abord, qu'est-ce qu'un "calcul coûteux" ? La concaténation de chaînes est-elle coûteuse ? Ou le tri d'un tableau de 300 éléments ? Ou l'exécution d'une expression régulière sur un texte de 5000 mots ? Je ne sais pas. Et vous non plus. Et personne ne le sait jusqu'à ce que ce soit réellement mesuré :
- sur un appareil représentatif de votre base d'utilisateurs
- en contexte
- en comparaison avec le reste des choses qui se passent en même temps
- en comparaison avec ce que c'était avant ou l'état idéal
Le tri d'un tableau de 300 éléments sur mon ordinateur portable, même avec un CPU ralenti 6 fois, prend moins de 2ms. Mais sur un vieux téléphone Android 2, cela pourrait prendre une seconde.
L'exécution d'une expression régulière sur un texte qui prend 100ms semble lente. Mais si elle est exécutée suite à un clic sur un bouton, une fois de temps en temps, enfouie quelque part dans l'écran des paramètres, alors c'est presque instantané. Une expression régulière qui prend 30ms à s'exécuter semble assez rapide. Mais si elle est exécutée sur la page principale à chaque mouvement de souris ou événement de défilement, c'est impardonnable et doit être améliorée.
Cela dépend toujours. "Mesurer d'abord" devrait être la pensée par défaut lorsqu'il y a une envie d'envelopper quelque chose dans useMemo parce que c'est un "calcul coûteux".
La deuxième chose à considérer est React. En particulier, le rendu des composants par rapport aux calculs JavaScript bruts. Plus probablement, tout ce qui est calculé dans useMemo sera de toute façon un ordre de grandeur plus rapide que le re-render des éléments réels. Par exemple, le tri de ce tableau de 300 éléments sur mon ordinateur portable a pris moins de 2ms. Le re-render des éléments de liste de ce tableau, même quand c'était juste de simples boutons avec du texte, a pris plus de 20ms. Si je veux améliorer les performances de ce composant, la meilleure chose à faire serait de se débarrasser des re-renders inutiles de tout, pas de mémoriser quelque chose qui prend moins de 2ms.
Donc un ajout à la règle "mesurer d'abord", quand il s'agit de mémorisation, devrait être : "n'oubliez pas de mesurer combien de temps prend le re-render des éléments du composant également." Et si vous enveloppez chaque calcul JavaScript dans useMemo et gagnez 10ms, mais que le re-render des composants réels prend toujours près de 200ms, alors à quoi bon ? Tout ce que cela fait, c'est compliquer le code sans gain visible.
Et enfin, useMemo n'est utile que pour les re-renders. C'est tout le but et son fonctionnement. Si votre composant ne se re-render jamais, alors useMemo ne fait rien.
Plus que rien, il force React à faire un travail supplémentaire lors du rendu initial. N'oubliez pas : la toute première fois que le hook useMemo s'exécute, lorsque le composant est monté pour la première fois, React doit le mettre en cache. Il utilisera un peu de mémoire et de puissance de calcul pour cela, qui autrement serait libre. Avec un seul useMemo, l'impact ne sera pas mesurable, bien sûr. Mais dans les grandes applications, avec des centaines d'entre eux dispersés partout, cela peut effectivement ralentir de manière mesurable le rendu initial. Ce sera la mort par mille coupures à la fin.
Points clés à retenir#
Eh bien, c'est déprimant. Est-ce que tout cela signifie que nous ne devrions pas utiliser la mémorisation ? Pas du tout. Cela peut être un outil très précieux dans notre bataille pour les performances. Mais compte tenu des nombreuses mises en garde et complexités qui l'entourent, je recommanderais d'utiliser d'abord autant que possible les techniques d'optimisation basées sur la composition. React.memo devrait être le dernier recours lorsque toutes les autres choses ont échoué.
Et rappelons-nous :
- React compare les objets/tableaux/fonctions par leur référence, pas leur valeur. Cette comparaison se produit dans les dépendances des hooks et dans les props des composants enveloppés dans React.memo.
- La fonction inline passée comme argument à useMemo ou useCallback sera recréée à chaque re-render.
- useCallback mémorise la fonction elle-même, useMemo mémorise le résultat de son exécution.
- Mémoriser les props sur un composant n'a de sens que lorsque :
- Ce composant est enveloppé dans React.memo.
- Ce composant utilise ces props comme dépendances dans l'un des hooks.
- Ce composant passe ces props à d'autres composants, et ils ont l'une des situations ci-dessus.
- Si un composant est enveloppé dans React.memo et que son re-render est déclenché par son parent, alors React ne re-render pas ce composant si ses props n'ont pas changé. Dans tous les autres cas, le re-render se poursuivra comme d'habitude.
- Mémoriser toutes les props sur un composant enveloppé dans React.memo est plus difficile qu'il n'y paraît. Évitez de lui passer des valeurs non primitives qui proviennent d'autres props ou hooks.
- Lors de la mémorisation des props, rappelez-vous que "children" est aussi une prop non primitive qui doit être mémorisée.