React Hooks Personnalisés : useInView

React Hooks Personnalisés : useInView

·

8 min read

Précédemment, nous avons implémenté le Hook useAudio pour simplifier la gestion des fichiers audio. Aujourd'hui, on implémente un Hook qui suit la visibilité de nos composants à l'écran : useInView.

Motivation

Tout d'abord, penchons-nous sur un cas concret afin de motiver l'implémentation de notre Hook. L'exemple le plus évident est celui des scroll infinis, comme votre fil Facebook ou Instagram. Comme vous le savez peut-être, ces applications ne chargent pas votre fil dans son entièreté (si c'était le cas, l'arbre DOM contiendrait des centaines, voire des milliers d'éléments, ce qui dégraderait considérablement leurs performances). À la place, elles chargent un certain nombre de publications, disons une dizaine et attendent que vous scrolliez un peu pour en charger une dizaine de plus. Lorsque vous continuerez de scroller, elles chargeront une dizaine de publications supplémentaires, et ainsi de suite. De ce fait, en faisant défiler votre fil, vous avez l'impression d'être face à une liste infinie.

Pour parvenir à ce résultat, ces applications utilisent les Intersection Observers, qui attendent qu'un certain élément choisi soit au-dessus d'un certain seuil (appelé root margin) pour déclencher le chargement de nouveaux posts, comme le montre la figure ci-dessous.

Schéma Intersection Observers

Par exemple, lorsque le 4ᵉ élément sera visible à plus de 25% au-delà ce cette root margin (ce seuil peut être configuré), une fonction callback sera appelée pour afficher de nouveaux posts. Ce comportement est celui que notre Hook devra implémenter.

Si les Intersection Observers vous semblent flous, pas d'inquiétude : j'ai créé une petite démonstration avec laquelle vous pouvez jouer pour mieux comprendre leur fonctionnement. Si cela vous intéresse, vous pouvez y accéder via ce lien CodeSandbox.

Nous pouvons maintenant passer aux choses sérieuses et implémenter ce Hook. 👨🏻‍💻

Implémentation

Avant toute chose, parlons de sa signature : comment souhaitons-nous l'utiliser ? Premièrement, nous voulons qu'il nous retourne une valeur booléenne nous indiquant si l'élément cible est visible ou non.

const isIntersecting = useInView();

Mais nous avons un premier problème : comme nous l'avons vu dans le schéma de la section précédente, la root margin n'est pas directement en bas de l'écran. À la place, elle peut être plus bas (ou plus haut), et nous pouvons définir nous-même ce décalage (par exemple, 100px). Nous pouvons également définir l'élément root, au sein duquel l'élément cible pourra être visible ou non, ainsi que le seuil de visibilité pour déclencher la fonction de callback (0%, 50%, 100%, ...). Dans notre exemple de scroll infini, l'élément root correspond à la zone d'affichage du navigateur (viewport), la root margin pourrait être définie à 100px, le seuil d'affichage pourrait être à 0, et l'élément cible pourrait être le pied de page (footer). De ce fait, dès que le footer arrivera à 100px du bas de l'écran, la fonction de chargement de nouveaux posts sera déclenchée.

Sur la documentation officielle des Intersection Observers, on peut voir qu'un objet options peut être passé en argument lors de l'instanciation. Nous utiliserons ce même objet afin de garder la même logique plutôt que créer la nôtre, ce qui pourrait être perturbant pour nos collègues par exemple. Ainsi, la signature de notre Hook sera modifiée comme suit :

const options = { root: someElement, rootMargin: '100px', threshold: 0.25 };
const isIntersecting = useInView(options);

Chaque clé de l'objet options est optionnelle.

Voilà qui est mieux. Nous avons cependant un dernier souci : à quel moment définissons-nous l'élément cible ? De plus, nous ne voulons pas nous embêter avec les méthodes natives du DOM, telles que document.querySelector ou document.findElementById. Au lieu de ça, nous allons utiliser les refs (références) React pour nous simplifier la tâche et être plus dans la logique de composants. Notre Hook devra donc recevoir un autre paramètre : la ref de l'élément cible.

const target = useRef(null)
const options = { ... }
const isIntersecting = useInView(target, options)

// ...

return <p ref={target}>Loading...</p>

À noter que le paramètre options est totalement optionnel et pourra donc être omis.

Parfait, nous sommes désormais prêts à nous attaquer concrètement à l'implémentation. Commençons par définir le squelette du Hook.

const useInView = (target, options = {}) => {
  const [isIntersecting, setIsIntersecting] = useState(false);
  return isIntersecting;
};

Ensuite, nous allons créer la logique des Intersection Observers. Pour ce faire, on va instancier un IntersectionObserver dès que le Hook sera monté. Comme nous l'avons vu précédemment, le constructeur prend en paramètre la fonction callback qui sera exécutée lorsque l'élément cible atteindra le seuil défini. Cette fonction recevra en argument la liste des entrées concernées, chacune correspondant à un seuil franchi pour un des éléments observés (dans notre cas, nous n'aurons qu'un seul élément cible et cette liste contiendra donc un seul élément). La fonction callback est donc aussi simple que ça :

const callback = entries => {
  setIsIntersecting(entries[0].isIntersecting);
};

Génial. Avec cette fonction callback et les options que nous recevons en arguments, nous pouvons instancier un IntersectionObserver.

const useInView = (target, options = {}) => {
  const [isIntersecting, setIsIntersecting] = useState(false);
  const [observer, setObserver] = useState(null);

  useEffect(() => {
    const callback = entries => {
      setIsIntersecting(entries[0].isIntersecting);
    };

    const _observer = new IntersectionObserver(callback, options);
    setObserver(_observer);
  }, []);

  return isIntersecting;
};

Pour l'instant, rien ne se passe. En effet, nous devons observer l'élément cible après avoir créé cette instance, afin que la fonction callback soit appelée en conséquence lorsque cela sera nécessaire.

useEffect(() => {
  const callback = entries => {
    setIsIntersecting(entries[0].isIntersecting);
  };

  const _observer = new IntersectionObserver(callback, options);
  _observer.observe(target);
  setObserver(_observer);
}, []);

Super. Maintenant, nous devons faire attention à quelque chose : lorsque le Hook sera démonté, nous devrons arrêter d'observer l'élément cible afin que la fonction callback ne soit pas appelée lorsqu'elle ne devrait pas. Pour ce faire, nous allons renvoyer une fonction de nettoyage dans l'appel à useEffect, qui se chargera de se déconnecter de l'observer quand le Hook sera démonté.

useEffect(() => {
  const callback = entries => {
    setIsIntersecting(entries[0].isIntersecting);
  };

  const _observer = new IntersectionObserver(callback, options);
  _observer.observe(target);
  setObserver(_observer);

  return () => {
    observer?.disconnect();
  };
}, []);

On utilise ici l'opérateur de chaînage optionnel (?.) pour éviter de rencontrer des erreurs si, pour une raison ou une autre, l'observer est null.

Voilà qui est beaucoup mieux ! Nous nous rapprochons de la fin. Il ne nous reste qu'une chose à régler : nous devons écouter les changements de valeur de nos arguments. Par exemple, si l'élément cible ou le seuil de visibilité changent, nous devons mettre à jour l'observer en conséquence. Pour ce faire, nous allons les ajouter à la liste de dépendences du Hook useEffect, dans lequel nous nous déconnecterons du précédent observer (s'il existe) pour en créer une nouvelle instance avec les valeurs mises à jour.

useEffect(() => {
  const callback = entries => {
    setIsIntersecting(entries[0].isIntersecting);
  };

  observer?.disconnect(); // On se déconnecte du précédent observer

  // target.current peut être null, auquel cas on ne fait rien
  if (target.current) {
    const _observer = new IntersectionObserver(callback, options);
    _observer.observe(target);
    setObserver(_observer);
  }

  return () => {
    observer?.disconnect();
  };
}, [target.current, options.root, options.rootMargin, options.threshold]);

Eh voilà, notre Hook est maintenant terminé et prêt à être utilisé. Voici le résultat final.

const useInView = (target, options = {}) => {
  const [isIntersecting, setIsIntersecting] = useState(false);
  const [observer, setObserver] = useState(null);

  useEffect(() => {
    const callback = entries => {
      setIsIntersecting(entries[0].isIntersecting);
    };

    observer?.disconnect();

    if (target.current) {
      const _observer = new IntersectionObserver(callback, options);
      _observer.observe(target);
      setObserver(_observer);
    }

    return () => {
      observer?.disconnect();
    };
  }, [target.current, options.root, options.rootMargin, options.threshold]);

  return isIntersecting;
};

Utilisation

Dans la première partie de cet article, nous avons parlé de Facebook pour introduire les Intersection Observers. Nous allons simuler un concurrent, que l'on appellera Fuzebook. Dans l'extrait de code ci-dessous, nous chargeons le fil de l'utilisateur. L'élément cible de notre Intersection Observer sera un paragraphe situé en bas de la page, qui contient le texte "Loading more posts...". On pourrait également utiliser une autre cible, comme un spinner ou le pied de page. Dans tous les cas, cet élément ne devrait pas apparaître à l'écran, car nous définissons une root margin de 150px. Il pourrait éventuellement être visible si l'appel à fetch (de la fonction getPosts) prend quelques secondes, par exemple en cas de mauvaise connexion internet.

Dans notre exemple, le scroll est infini : nous n'atteindrons jamais le bas de la page étant donné que le fil contient des centaines, voire des milliers de publications. Il serait donc inutile d'avoir un pied de page par exemple, car on ne le verrait jamais. Si la section de scroll de votre application contient moins d'éléments (moins d'une centaine), vous devriez gérer le cas où il n'y plus d'éléments à charger (il suffirait de masquer l'élément cible, c'est-à-dire le paragraphe en bas de la page ou le spinner).

Voici le code du composant principal de notre application Fuzebook.

const App = () => {
  const posts = useArray([]);
  const page = useCounter(1);
  const loadingElement = useRef(null);
  const isIntersecting = useInView(loadingElement, {
    threshold: 1,
    rootMargin: '150px',
  });

  // Chargement de nouveaux posts
  useEffect(() => {
    getPosts(page.value).then(newPosts => {
      posts.concat(newPosts);
    });
  }, [page.value]);

  useEffect(() => {
    if (isIntersecting) {
      page.increment();
    }
  }, [isIntersecting]);

  return (
    <div className="App">
      <h1>Fuzebook</h1>
      <ul>
        {posts.value.map((post, i) => (
          <Card key={i} post={post} />
        ))}
      </ul>
      <p ref={loadingElement}>Loading more posts...</p>
    </div>
  );
};

Comme vous pouvez le voir, nous avons réutilisé 2 Hooks personnalisés que nous avons implémenté dans des articles précédents : useArray et useCounter. Cela simplifie le code du composant App, aboutissant à un code bien plus propre et plus lisible. Le composant Card affiche une simple publication et la fonction getPosts (qui renvoie une promesse) récupère des publications depuis une API quelconque.

Conclusion

Cet article clôture la série sur les Hooks personnalisés en React, durant laquelle nous avons découvert comment extraire de la logique au sein de fonctions réutilisables afin de simplifier notre code. Cela nous permet également de réutiliser de la logique commune à travers notre application sans avoir à dupliquer de code. De ce fait, nous suivons le Principe de Responsabilité Unique (ou SRP : Simple Responsability Principle), à la fois pour nos composants (qui se concentrent désormais uniquement sur leur responsabilité) et pour nos Hooks. J'espère que vous avez pris autant de plaisir à suivre cette série que j'en ai eu à l'écrire, et je vous donne rendez-vous dans de prochains articles. 👋


Code source disponible sur CodeSandbox.

Did you find this article valuable?

Support Ludal by becoming a sponsor. Any amount is appreciated!