React Hooks #3 : La performance avec useCallback et useMemo

Voici donc la 3eme et dernière partie de la série des Hooks. Vous commencez à être aguerris et vous voulez finir ce voyage de connaissance. Si vous souhaitez tout de même faire un petit rappel, je vous conseille de lire (relire?) les 2 parties précédentes ainsi que l’annexe sur la réutilisation :

Ici on va parler performance et plus particulièrement de mémoïsation et de référence.

Pour rappel, la mémoïsation est une technique qui va permettre de mémoriser la valeur retournée par une fonction. Cela va permettre d’éviter l’exécution à plusieurs reprises d’une fonction gourmande en ressources.

// Fonction
function test(arg) {
  return calcule(arg);
}

// Variable qui permettra de sauvegarder le résultat
const mem = {};

// Fonction mémoïsée
function memoTest(arg) {
  // Si la fonction a déjà été appelée avec cet argument, on retourne le résultat connu
  if (mem[arg]) return mem[arg];
  // On stocke le résultat de la fonction
  mem[arg] = calcule(arg);
  // et on le retourne
  return mem[arg];
}

Ainsi si notre fonction est appelée successivement avec les mêmes paramètres, le résultat n’est calculé qu’une seule fois.

useMemo

Certains composants React peuvent demander un calcul couteux en termes de performance. Cela a pour principale conséquence de d’augmenter le temps d’exécution et donc de dégrader l’expérience utilisateur (l’affichage peu saccadé et perdre en fluidité).

Grâce à useMemo nous pouvons appliquer le principe de mémoïsation facilement.

On va le retrouver sous cette forme :

const memoizedValue = useMemo(() => computeExpensiveValue(arg1, arg2), [
  arg1,
  arg2,
]);

On a donc :

  • memoizedValue: la valeur retournée par la fonction computeExpensiveValue mémoïsée
  • computeExpensiveValue : la fonction que vous souhaitez mémoïser
  • (arg1, arg2) : les arguments passés à la fonction qui peuvent être des propriétés ou des états
  • [arg1, arg2] : les mêmes arguments mais cette fois passé en dépendance du useMemo. Si ces les valeurs de ces dépendances changent, la fonction passée sera exécutée de nouveau.

Prenons l’exemple suivant : un bouton simple qui incrémente un compteur. Et dans ce composant, il y en a un autre qui se rafraichit à chaque fois que le compteur est modifié. L’affichage de cet autre composant est très lent et dégrade l’utilisation du compteur.

function fibonacci(num) {
  if (num <= 1) return 1;
  return fibonacci(num - 1) + fibonacci(num - 2);
}

// Notre composant consommateur
function PromptFibonacciNumber({ number }) {
  const fibonnacciNumber = fibonacci(number);
  return fibonnacciNumber;
}

// Notre compteur
function Counter() {
  const [count, setCount] = useState(0);
  return (
    <>
      {count}
      <button onClick={() => setCount(count + 1)}> Add </button>
      <PromptFibonacciNumber number={42} />
    </>
  );
}

Je vous laisse essayer ci-dessous pour illustrer le ralentissement à chaque incrémentation :

Pour éviter que les calculs soient intempestifs, on va utiliser useMemo sur la fonction mise en cause.

function fibonacci(num) {
  if (num <= 1) return 1;
  return fibonacci(num - 1) + fibonacci(num - 2);
}

function PromptFibonacciNumber({ number }) {
  // `fibonnacciNumber` est mémoïsée grâce à `useMemo`
  const fibonnacciNumber = useMemo(
    () => fibonacci(number),
    // la fonction ne sera exécutée de nouveau que lorsque la propriété `number` changera
    [number]
  );

  return fibonnacciNumber;
}

L’exo

Dans cet exercice, on ne va pas travailler avec des fonctions qui sont “réellement” consommatrice. On mettra en avant la fonction via un console.log.

Le cas suivant est plutôt commun. Notre composant reçoit une liste d’élément, la tri (effectue une modification) et l’affiche.

Si vous choisissez un Pokémon, la liste sera filtrée de nouveau. Pour réussir l’exercice, il faut que le log Expensive function soit affiché uniquement au premier rendu.

L’exercice -> useMemo - Exercice

À noter

À utiliser avec parcimonie

Attention, useMemo permet effectivement d’améliorer les performances dans certains cas mais peut avoir l’effet inverse dans d’autre. Ce Hook ajoute une mécanique supplémentaire à votre composant. Ainsi, si vous ne détectez pas de problème de performance, c’est peut-être que ce n’est pas nécessaire. Utiliser useMemo avec des arguments qui changent régulièrement peut avoir un impact non négligeable sur la RAM, car les inputs / outputs sont conservés en mémoire.

Performance et non sémantique

useMemo est à utiliser comme un moyen d’améliorer la performance. React ne garantit pas que les valeurs soit mémorisée pour de bon et pourrait les recalculer entre 2 rendus. Si vous souhaitez qu’une valeur ne soit calculé qu’une fois au premier rendu du composant, vous pouvez utiliser useRef.

Solution

Voici une des solutions possibles :

useCallback

Ce Hook va permettre de mémoïser une fonction (contrairement à useMemo qui mémoïse le retour d’une fonction).

Dans une fonction définie dans un composant React, vous allez utiliser des propriétés (props) ou des valeurs d’état (state). Si le composant est fonctionnel, la référence de cette fonction changera à chaque rendu. Elle est instanciée une nouvelle fois. Ça parait anodin mais si cette fonction est passée à un composant enfant, c’est comme si vous lui passiez une nouvelle props. Et donc le composant enfant sera re-calculé à chaque fois qu’un changement est fait dans le composant parent.

On va passer sur un exemple pour que ça soit un peu plus clair.

const { useState, useCallback, memo } = React;

function PokeWorld({ pokemons }) {
  const [selected, setSelected] = useState(null);
  // Fonction commune à passer au composant enfant
  const handleClick = useCallback(
    (pokemon) => {
      setSelected(pokemon);
    },
    [pokemons]
  );

  return (
    <>
      <span>{selected && selected.name}</span>
      <BigPokedexList pokemons={pokemons} handleClick={handleClick} />
    </>
  );
}

// Composant mémoïsé qui n'effectuera de rendu que si la valeur props changent
const BigPokedexList = React.memo(({ pokemons, handleClick }) => {
  return (
    <ul>
      {pokemons.map(
        (pokemon) =>
          console.log(`render ${pokemon.name}`) || (
            <li key={pokemon.name}>
              {
                // À chaque clic sur un bouton, le composant BigPokedexList et tous ses composants enfants sont rafraichis
              }
              <button onClick={() => handleClick(pokemon)}>
                {pokemon.name}
              </button>
            </li>
          )
      )}
    </ul>
  );
});

Ici à chaque clic sur un bouton, la fonction handleClick est régénéré.

Voici les étapes:

  1. Clic sur le bouton
  2. Mise à jour du state du composant PokeWorld
  3. Exécution du composant pour déterminer quoi mettre à jour
  4. Re-génération des fonctions du composant PokeWorld durant l’exécution
  5. Nouvelle fonction handleClick
  6. Rafraichissement du composant BigPokedexList

On va donc passer par useCallback pour garder la même instance de handleClick.

function PokeWorld({ pokemons }) {
  const [selected, setSelected] = useState(null);
  // Fonction commune à passer au composant enfant
  const handleClick = useCallback(
    (pokemon) => {
      setSelected(pokemon);
    },
    [pokemons]
  );

  return (
    <>
      <span>{selected && selected.name}</span>
      <BigPokedexList pokemons={pokemons} handleClick={handleClick} />
    </>
  );
}

Je vous laisse tester la différence dans la console avec et sans le useCallback (Que vous devrez implémenter) :

Rappel sur memo

memo est un HOC (High Order Component) qui permet de reproduire la fonction shouldComponentUpdate des composants en classe sur des composants fonctionnels. À chaque rendu, les props sont comparées (via un Object.is). Si les props sont égales, le composant ne se rafraichi pas.

l’exo

Dans cet exercice, on va générer une nouvelle couleur pour chaque bouton à chaque fois que le composant se rend. Le problème c’est qu’à chaque fois qu’on choisit une couleur, elles changent toutes.

Utilisez useCallback pour éviter que les couleurs ne changent à chaque sélection.

L’exercice -> useCallback - Exercice

À noter

Comme pour useMemo

Ce Hook permet de faire de l’amélioration de performance. Comme pour useMemo, il ne faut pas l’utiliser partout non plus. Cela ajoute une mécanique de mémoïsation sur votre fonction. Ce n’est pas toujours un gain de performance.

Solution

Voici une des solutions possibles:

Conclusion

Voilà donc les Hooks qui permettent d’améliorer la performance. Vous ne les utiliserez peut-être pas régulièrement mais ça reste des nouveaux outils. Ils seront là en cas de besoin.

Comme répété plus haut, il ne faut pas non plus les utiliser tout le temps. Ils sont là pour l’optimisation, pas la construction. Ces Hooks répondent à des problématiques précises qu’il faut avoir en tête, les utiliser implique de connaitre leur fonctionnement pour en tirer parti. Faire appel à la mémoïzation à tort et à travers causera en général plus de problèmes de performance qu’il n’en résoudra.

Dans les exercices précédents, j’ai essayé de reproduire des cas d’usage qui restent un peu éloigné de la réalité. Essayez de refaire le tour de vos projets actuels. Ils auront peut-être besoin d’un petit coup d’optimisation.

Comme d’habitude, l’exercice et la mise en pratique vous permettront de mieux comprendre et de maitriser ce nouveau savoir.

Bonne chance à vous!

Références