Références de rappel React : Ce qu’elles sont et comment les utiliser

Pendant le développement, nous avons souvent besoin d’une interaction directe avec les éléments DOM. Dans de tels cas, React nous fournit un mécanisme appelé refs, qui permet d’accéder aux éléments après leur rendu. Le plus couramment, nous utilisons des références d’objet standard via useRef (appelons-les ainsi), mais il existe une autre approche appelée callback refs. Cette méthode offre une flexibilité supplémentaire et un contrôle sur le cycle de vie des éléments, nous permettant d’effectuer certaines actions spécifiques aux moments précis où les éléments sont attachés ou détachés du DOM. 

Dans cet article, je souhaite expliquer ce que sont les références de rappel et comment elles fonctionnent, discuter des pièges que vous pourriez rencontrer, et montrer des exemples de leur utilisation.

Qu’est-ce que les références de rappel et comment fonctionnent-elles?

Les références de rappel vous donnent un contrôle plus granulaire sur la façon dont les références sont attachées par rapport aux références d’objet. Voyons comment elles fonctionnent en pratique:

  1. Montage. Lorsqu’un élément est monté dans le DOM, React appelle la fonction de référence avec l’élément DOM lui-même. Cela vous permet d’effectuer des actions avec l’élément immédiatement après son apparition sur la page.
  2. Démontage. Lorsqu’un élément est démonté, React appelle la fonction de référence avec null. Cela vous donne l’opportunité de nettoyer ou d’annuler les actions associées à cet élément.

Exemple: Suivi du montage et du démontage

TypeScript

 

import React, { useCallback, useState } from 'react';

function MountUnmountTracker() {
  const [isVisible, setIsVisible] = useState(false);

  const handleRef = useCallback((node: HTMLDivElement | null) => {
    if (node) {
      console.log('Element mounted:', node);
    } else {
      console.log('Element unmounted');
    }
  }, []);

  return (
    <div>
      <button onClick={() => setIsVisible((prev) => !prev)}>
        {isVisible ? 'Hide' : 'Show'} element
      </button>
      {isVisible && <div ref={handleRef}>Tracked element</div>}
    </div>
  );
}

export default MountUnmountTracker;

À chaque fois que nous basculons la visibilité de l’élément, la fonction handleRef est appelée avec soit le nœud soit null, ce qui nous permet de suivre le moment où l’élément est attaché ou détaché.

Problèmes courants et solutions

Problème : Invocations répétées de références de rappel

Un problème fréquent lors de l’utilisation de références de rappel est la création répétée de la fonction de référence à chaque réexécution du composant. En raison de cela, React pense qu’il s’agit d’une nouvelle référence, appelle l’ancienne avec null (la nettoie) puis initialise la nouvelle, même si notre élément ou composant n’a pas réellement changé. Cela peut entraîner des effets secondaires indésirables.

Exemple du problème

Considérons un composant Basique qui a un bouton pour basculer la visibilité d’un div avec une référence de rappel, ainsi qu’un autre bouton pour forcer une réexécution du composant:

TypeScript

 

import React, { useState, useReducer } from 'react';

function Basic() {
  const [showDiv, setShowDiv] = useState(false);
  const [, forceRerender] = useReducer((v) => v + 1, 0);

  const toggleDiv = () => setShowDiv((prev) => !prev);

  const refCallback = (node: HTMLDivElement | null) => {
    console.log('div', node);
  };

  return (
    <div>
      <button onClick={toggleDiv}>Toggle Div</button>
      <button onClick={forceRerender}>Rerender</button>
      {showDiv && <div ref={refCallback}>Example div</div>}
    </div>
  );
}

export default Basic;

Chaque fois que vous cliquez sur le bouton Réexécuter, le composant se réexécute, créant une nouvelle fonction refCallback. En conséquence, React appelle l’ancienne refCallback(null) puis la nouvelle refCallback(nœud), même si notre élément avec la référence n’a pas changé. Dans la console, vous verrez div null puis div [nœud] à tour de rôle, de manière répétitive. De toute évidence, nous voulons généralement éviter des appels inutiles de ce genre.

Solution : Mémoriser la référence de rappel avec useCallback

Éviter cela est assez simple : il suffit d’utiliser useCallback pour mémoriser la fonction. De cette façon, la fonction reste inchangée à travers les réexécutions, sauf si ses dépendances changent.

TypeScript

 

import React, { useState, useCallback, useReducer } from 'react';

function Basic() {
  const [showDiv, setShowDiv] = useState(false);
  const [, forceRerender] = useReducer((v) => v + 1, 0);

  const toggleDiv = () => setShowDiv((prev) => !prev);

  const refCallback = useCallback((node: HTMLDivElement | null) => {
    console.log('div', node);
  }, []);

  return (
    <div>
      <button onClick={toggleDiv}>Toggle Div</button>
      <button onClick={forceRerender}>Rerender</button>
      {showDiv && <div ref={refCallback}>Example div</div>}
    </div>
  );
}

export default Basic;

Maintenant, refCallback est créé une seule fois, lors du rendu initial. Il ne déclenchera pas d’appels supplémentaires lors des re-renders ultérieurs, évitant les rappels inutiles et améliorant les performances.

L’ordre des références de rappel, useLayoutEffect et useEffect

Avant de parler de comment utiliser les références de rappel dans votre code pour résoudre des problèmes spécifiques, comprenons comment les références de rappel interagissent avec les crochets useEffect et useLayoutEffect afin que vous puissiez organiser correctement l’initialisation et le nettoyage des ressources.

Ordre d’exécution

  1. référence de rappel – appelée immédiatement après le rendu des éléments DOM, avant l’exécution des effets des crochets
  2. useLayoutEffect – s’exécute après toutes les mutations du DOM mais avant que le navigateur ne peigne
  3. useEffect – s’exécute après que le composant a fini de se rendre à l’écran
TypeScript

 

import React, { useEffect, useLayoutEffect, useCallback } from 'react';

function WhenCalled() {
  const refCallback = useCallback((node: HTMLDivElement | null) => {
    if (node) {
      console.log('Callback ref called for div:', node);
    } else {
      console.log('Callback ref detached div');
    }
  }, []);

  useLayoutEffect(() => {
    console.log('useLayoutEffect called');
  }, []);

  useEffect(() => {
    console.log('useEffect called');
  }, []);

  return (
    <div>
      <div ref={refCallback}>Element to watch</div>
    </div>
  );
}

export default WhenCalled;

Sortie de la console

  1. Appel de la référence de rappel pour le div : [élément div]
  2. useLayoutEffect appelé
  3. useEffect appelé

Cette séquence nous indique que les références de rappel sont déclenchées avant les crochets comme useLayoutEffect et useEffect, ce qui est essentiel à garder à l’esprit lors de l’écriture de votre logique.

Quels problèmes les références de rappel résolvent-elles dans le code ?

Tout d’abord, reproduisons un problème généralement rencontré avec les références d’objets régulières afin de pouvoir ensuite le résoudre avec des références de rappel.

TypeScript

 

import { useCallback, useEffect, useRef, useState } from 'react';

interface ResizeObserverOptions {
  elemRef: React.RefObject<HTMLElement>;
  onResize: ResizeObserverCallback;
}

function useResizeObserver({ elemRef, onResize }: ResizeObserverOptions) {
  useEffect(() => {
    const element = elemRef.current;

    if (!element) {
      return;
    }

    const resizeObserver = new ResizeObserver(onResize);

    resizeObserver.observe(element);

    return () => {
      resizeObserver.unobserve(element);
    };
  }, [onResize, elemRef]);
}

export function UsageDom() {
  const [bool, setBool] = useState(false);
  const elemRef = useRef<HTMLDivElement>(null);

  const handleResize = useCallback((entries: ResizeObserverEntry[]) => {
    console.log('resize', entries);
  }, []);

  useResizeObserver({ elemRef, onResize: handleResize });

  const renderTestText = () => {
    if (bool) {
      return <p ref={elemRef}>Test text</p>;
    }

    return <div ref={elemRef}>Test div</div>;
  };

  return (
    <div style={{ width: '100%', textAlign: 'center' }}>
      <button onClick={() => setBool((v) => !v)}>Toggle</button>
      {renderTestText()}
    </div>
  );
}

Nous ne plongerons pas dans chaque détail ici. En résumé, nous suivons la taille de notre élément div ou p via un ResizeObserver. Au départ, tout fonctionne bien : lors du montage, nous pouvons obtenir la taille de l’élément, et les redimensionnements sont également signalés dans la console.

Le véritable problème commence lorsque nous basculons l’état pour changer l’élément que nous observons. Lorsque nous changeons d’état et remplaçons ainsi l’élément suivi, notre ResizeObserver ne fonctionne plus correctement. Il continue d’observer le premier élément, qui a déjà été supprimé du DOM ! Même basculer à nouveau vers l’élément d’origine n’aide pas car l’abonnement à ce nouvel élément ne s’attache jamais correctement.

Note : La solution suivante est plus représentative de ce que vous pourriez écrire dans un contexte de bibliothèque, nécessitant un code universel. Dans un projet réel, vous pourriez le résoudre par une combinaison de drapeaux, d’effets, etc. Mais dans le code de bibliothèque, vous n’avez pas connaissance du composant spécifique ou de son état. C’est exactement le scénario où les références de rappel peuvent nous aider.

TypeScript

 

import { useCallback, useRef, useState } from 'react';

function useResizeObserver(onResize: ResizeObserverCallback) {
  const roRef = useRef<ResizeObserver | null>(null);

  const attachResizeObserver = useCallback(
    (element: HTMLElement) => {
      const resizeObserver = new ResizeObserver(onResize);
      resizeObserver.observe(element);
      roRef.current = resizeObserver;
    },
    [onResize]
  );

  const detachResizeObserver = useCallback(() => {
    roRef.current?.disconnect();
  }, []);

  const refCb = useCallback(
    (element: HTMLElement | null) => {
      if (element) {
        attachResizeObserver(element);
      } else {
        detachResizeObserver();
      }
    },
    [attachResizeObserver, detachResizeObserver]
  );

  return refCb;
}

export default function App() {
  const [bool, setBool] = useState(false);

  const handleResize = useCallback((entries: ResizeObserverEntry[]) => {
    console.log('resize', entries);
  }, []);

  const resizeRef = useResizeObserver(handleResize);

  const renderTestText = () => {
    if (bool) {
      return <p ref={resizeRef}>Test text</p>;
    }

    return <div ref={resizeRef}>Test div</div>;
  };

  return (
    <div style={{ width: '100%', textAlign: 'center' }}>
      <button onClick={() => setBool((v) => !v)}>Toggle</button>
      {renderTestText()}
    </div>
  );
}

Comme vous pouvez le voir, nous avons réécrit notre hook useResizeObserver pour utiliser une référence de rappel. Nous passons simplement une fonction de rappel (mémorisée) qui doit s’exécuter lors du redimensionnement, et peu importe combien de fois nous basculons les éléments, notre fonction de rappel pour le redimensionnement fonctionne toujours. C’est parce que l’observateur s’attache aux nouveaux éléments et se détache des anciens au moment exact où nous le souhaitons, grâce à la référence de rappel.

Le principal avantage ici est que le développeur utilisant notre hook n’a plus besoin de se soucier de la logique d’ajout/suppression d’observateurs en arrière-plan – nous avons encapsulé cette logique au sein du hook. Le développeur doit simplement passer un callback à notre hook et attacher la référence retournée à ses éléments.

Combiner plusieurs références en une seule

Voici un autre scénario où les références de callback viennent à la rescousse :

TypeScript

 

import { useEffect, useRef } from 'react';
import { forwardRef, useCallback } from 'react';

interface InputProps {
  value?: string;
  onChange?: React.ChangeEventHandler<HTMLInputElement>;
}

const Input = forwardRef(function Input(
  props: InputProps,
  ref: React.ForwardedRef<HTMLInputElement>
) {
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    if (!inputRef.current) {
      return;
    }

    console.log(inputRef.current.getBoundingClientRect());
  }, []);

  return <input {...props} ref={ref} />;
});

export function UsageWithoutCombine() {
  const inputRef = useRef<HTMLInputElement | null>(null);

  const focus = () => {
    inputRef.current?.focus();
  };

  return (
    <div>
      <Input ref={inputRef} />
      <button onClick={focus}>Focus</button>
    </div>
  );
}

Dans le code ci-dessus, nous avons un composant input simple sur lequel nous définissons une référence, en la récupérant des props à l’aide de forwardRef. Mais comment utilisons-nous inputRef à l’intérieur du composant Input si nous avons également besoin de la référence de l’extérieur pour quelque chose comme le focus ? Peut-être voulons-nous faire autre chose dans le composant input lui-même, comme getBoundingClientRect. Remplacer la prop ref par notre ref interne signifie que le focus depuis l’extérieur ne fonctionnera plus. Alors, comment combinons-nous ces deux références ?

C’est là que les références de callback aident encore :

TypeScript

 

import { useEffect, useRef } from 'react';
import { forwardRef, useCallback } from 'react';

type RefItem<T> =
  | ((element: T | null) => void)
  | React.MutableRefObject<T | null>
  | null
  | undefined;

function useCombinedRef<T>(...refs: RefItem<T>[]) {
  const refCb = useCallback((element: T | null) => {
    refs.forEach((ref) => {
      if (!ref) {
        return;
      }

      if (typeof ref === 'function') {
        ref(element);
      } else {
        ref.current = element;
      }
    });
  }, refs);

  return refCb;
}

interface InputProps {
  value?: string;
  onChange?: React.ChangeEventHandler<HTMLInputElement>;
}

const Input = forwardRef(function Input(
  props: InputProps,
  ref: React.ForwardedRef<HTMLInputElement>
) {
  const inputRef = useRef<HTMLInputElement>(null);
  const combinedInputRef = useCombinedRef(ref, inputRef);

  useEffect(() => {
    if (!inputRef.current) {
      return;
    }

    console.log(inputRef.current.getBoundingClientRect());
  }, []);

  return <input {...props} ref={combinedInputRef} />;
});

export function UsageWithCombine() {
  const inputRef = useRef<HTMLInputElement | null>(null);

  const focus = () => {
    inputRef.current?.focus();
  };

  return (
    <div>
      <Input ref={inputRef} />
      <button onClick={focus}>Focus</button>
    </div>
  );
}

Explication

Nous avons implémenté un hook useCombinedRef où un développeur peut passer des références standard, des références de callback, et éventuellement null ou undefined. Le hook lui-même est juste un useCallback qui boucle sur le tableau des références. Si l’argument est null, nous l’ignorons, mais si la référence est une fonction, nous l’appelons avec l’élément ; si c’est un objet de référence standard, nous définissons ref.current sur l’élément.

De cette manière, nous fusionnons plusieurs refs en une seule. Dans l’exemple ci-dessus, à la fois getBoundingClientRect à l’intérieur du composant Input et l’appel externe à focus fonctionneront correctement.

Quels sont les changements dans React 19 concernant les refs de rappel?

Nettoyage automatique

React gère désormais automatiquement le nettoyage des refs de rappel lors du démontage des éléments, rendant la gestion des ressources plus simple. Voici un exemple que l’équipe React montre dans leur documentation:

TypeScript

 

 {
    // ref créé

    // NOUVEAU: renvoyer une fonction de nettoyage pour réinitialiser
    // la ref lorsque l'élément est retiré du DOM.
    return () => {
      // nettoyage de la ref
    };
  }}
/>

Vous pouvez en savoir plus à ce sujet dans l’article de blog officiel, où il est mentionné que bientôt, le nettoyage des refs via null pourrait être obsolète, laissant un seul moyen normalisé de nettoyer les refs.

Choix entre les refs normales et les refs de rappel

  • Utilisez des refs standard (useRef) lorsque vous avez juste besoin d’un accès simple à un élément DOM ou que vous voulez conserver une valeur entre les rendus sans actions supplémentaires à l’attachement ou au détachement.
  • Utilisez des refs de rappel lorsque vous avez besoin d’un contrôle plus granulaire sur le cycle de vie de l’élément, lorsque vous écrivez du code universel (par exemple, dans votre propre bibliothèque ou package), ou lorsque vous devez gérer plusieurs refs ensemble.

Conclusion

Les références de rappel dans React sont un outil puissant qui offre aux développeurs une flexibilité et un contrôle supplémentaires lorsqu’ils travaillent avec des éléments DOM. Dans la plupart des cas, les références d’objet standard via useRef suffisent pour les tâches quotidiennes, mais les références de rappel peuvent aider dans des scénarios plus complexes comme ceux que nous avons discutés ci-dessus.

Source:
https://dzone.com/articles/react-callback-refs-guide