ReactJS et les Hooks

Différences, avantages et inconvénients de cette nouvelle API par rapport à l’existant
Ce billet s’adresse aux développeurs curieux de découvrir les Hooks ou d’approfondir leurs connaissances de cette API.

Introduction

ReactJS in a nutshell

React est une bibliothèque JavaScript principalement maintenue par Facebook et utilisée pour construire des interfaces utilisateur.

Publié en Open Source sous license MIT, c’est le framework JavaScript qui possède aujourd’hui la plus grande part de marché des sites grand public.

ReactJS se définit comme :

  • déclaratif (les vues sont décrites de manière à retourner la même interface si appelées avec les mêmes propriétés, rendant le code plus prévisible et facile à déboguer)
  • basé sur des composants (blocs de code pouvant gérer un état interne et rendre in-fine des “morceaux” d’interfaces graphiques)
  • pouvant aisément coexister avec d’autres bibliothèques, frameworks ou technologies (même si à l’usage beaucoup préférerons l’utiliser comme unique framework)

Composants

Lorsque l’on utilise React pour gérer une partie de ses interfaces utilisateurs, il faut concevoir des composants.

Un composant est un bloc logique recevant des propriétés et retournant un fragment de JSX lorsqu’il est “rendu” (lorsqu’exécuté ou que sa fonction de rendu est appelée).

Le JSX est l’extension de syntaxe au JavaScript de React, il mélange HTML et Javascript en ajoutant un marquage supplémentaire pour appeler des composants d’une manière similaire aux balises HTML. Même si React sans JSX est possible, celui ci s’avère très pratique pour que le code retourné par nos composant reste clair et lisible.

Avant ReactJS version 16.8, deux types de composants se démarquaient clairement sur leur utilité, usage et forme :

  • Les class components, qui s’appuient sur les classes ES6, possèdent un état interne et peuvent exécuter du code à plusieurs étapes de leur cycle de vie (mise à jour, montage…)
  • Les function components, sans état interne, ce sont de simples fonctions qui prennent un unique objet de propriétés en argument et retournent un morceau de vue JSX.

Fonction

Les exemples suivants sont librement inspirés de la documentation de React sur les composants et l’état.

Si l’on souhaite créer un composant affichant un mot de bienvenue à un utilisateur en prenant son nom comme propriété, nous pouvons simplement écrire :

const Welcome = ({ name }) => <h1>Hello, {name}</h1>;

Classe

Pour lui indiquer l’heure en ajoutant une horloge mise à jour toutes les secondes et dont la valeur proviendrait et serait maintenue par le composant lui-même (en plus de lui souhaiter la bienvenue), il faut transformer cette fonction en classe :

class WelcomeClock extends React.Component {
  constructor(props) {
    super(props);
    this.state = { date: new Date() };
  }

  componentDidMount() {
    this.timerID = setInterval(() => this.tick(), 1000);
  }

  componentWillUnmount() {
    clearInterval(this.timerID);
  }

  tick() {
    this.setState({ date: new Date() });
  }
  render() {
    return (
      <div>
        <h1>Hello, {this.props.name}</h1>
        <h2>It is {this.state.date.toLocaleTimeString()}.</h2>
      </div>
    );
  }
}

D’un point de vue pratique, cette migration d’un type de composant à l’autre n’est pas gratuite : l’usage d’une classe JavaScript force en particulier à utiliser le mot-clé this pour accéder à l’état ou aux propriétés (forçant des bind ou fonctions anonymes lors de la définition de callback d’évènements) et requiert d’adopter une syntaxe très différente et plus verbeuse (constructeur et héritage de React.Component, entre-autre).

Cela ne serait pas un problème si nous savions déterminer en avance les différents types de composants dont aura besoin notre application. En pratique, il peut être fréquent d’avoir besoin d’ajouter un état dans un composant existant, et cela passera nécessairement par une migration en class component !

Une simplification, sans être nécessaire, aurait été bienvenue, et c’est justement l’un des avantages des Hooks !

Hooks

Présentation

Depuis ReactJS 16.8 et l’introduction des Hooks, il est désormais possible d’écrire des function components ayant un état interne.

Un Hook est une fonction permettant d’accéder aux fonctionnalités (d’état, de cycle de vie et autre) proposées par la bibliothèque depuis un function component. Par convention, les Hooks commencent par use, comme useState utilisé pour ajouter une propriété à l’état géré du composant.

Leur apparition permet :

  • de mutualiser la logique entre composants au travers de fonctions réutilisables (Custom Hooks)
  • de simplifier les composants devenus trop complexes dans leur gestion de cycle de vie en séparant les différents besoins en différentes fonctions (avec par exemple l’Effect Hook)
  • de profiter des possibilités de React sans avoir à utiliser de Classes ES6 ! (Même si la syntaxe change de manière assez radicale les fonctionnalités restes quasiment identiques, voir “Migration” pour plus de détail)

Simplification

Pour reprendre notre exemple précédent, voici comment nous pourrions réécrire WelcomeClock avec des Hooks :

const WelcomeClock = ({ name }) => {
  const [date, setDate] = useState(new Date());

  useEffect(() => {
    const timerID = setInterval(() => setDate(new Date()), 1000);
    return () => clearInterval(timerID);
  }, []);

  return (
    <div>
      <h1>Hello, {name}</h1>
      <h2>It is {date.toLocaleTimeString()}.</h2>
    </div>
  );
};

Nous sommes passés de 563 caractères pour un composant de Classe à 341 pour son équivalent fonctionnel avec Hooks. Cette réduction apportant le plus souvent une meilleure lisibilité du composant et simplification de la gestion de son cycle de vie (plusieurs useEffect plutôt qu’un unique componentDidUpdate).

Performance

Mais quel est l’impact de l’utilisation des Hooks dans une application React ?

Le sujet n’est pas abordé dans la documentation ou FAQ des Hooks mais, comme l’écrit Ryan Carniato, la différence semble minime et en faveur des Hooks (Hormis les Higher Order Components qui se révèlent plus rapides, sans être incompatibles avec l’usage des Hooks).

Migration

Avant de transformer tous ses composants classiques en Hooks au travers d’un refactoring séduisant par la simplification qu’il apportera à notre code (mais potentiellement périlleux), deux questions :

Est ce que tout composant sous forme de classe peut être hooked ?

Le sujet est traité dans la FAQ et l’on peut résumer en “l’écrasante majorité” (sauf pour les très peu utilisées méthodes de cycle de vie getSnapshotBeforeUpdate, getDerivedStateFromError, componentDidCatch et peut-être certaines bibliothèques et modules tiers non migrés qui pourraient se révéler incompatibles).

En pratique cependant la migration peut s’avérer plus compliquée que prévu : déclarer un useEffect n’est pas la même gymnastique mentale que passer dans un componentDidUpdate et cela est généralisable à la transcription de tout ce qui est présent hors du render() de la classe en logique hooked.

Ai-je toujours le droit d’utiliser des composants sous forme de classe ?

L’équipe derrière React définie les Hooks comme Opt-in, un outil de plus que l’on peut choisir d’utiliser et surtout qu’aucun plan de suppression des composants sous forme de classes n’est envisagé.

De plus, les Higher Order Components, HOC, ces composants qui en retournent un autre passé en argument et dont les propriétés auront pues être altérées ou re-mappées par le HOC, ont toujours quelques cas d’usages qui leurs sont propres (comme la transformation d’une propriété de composant en une autre).
Ainsi, même si l’écrasante majorité pourra être complètement remplacée par des Hooks, il nous arrivera parfois de devoir en employer de manière exceptionnelle, souvent pour pallier l’utilisation d’une bibliothèque tierce, et ceux-ci utilisent exclusivement des classes. Heureusement, ce cas d’usage reste rare et une application entièrement conçue en Hooks devrait pouvoir complètement s’en passer.

Une nouvelle logique

Malgré l’avantage pratique de pouvoir n’utiliser plus qu’un seul type de composant (ou presque) et de réduire le nombre de ligne à produire pour la même fonctionnalité, les Hooks possèdent une logique originale qu’il faudra comprendre et adopter pour éviter les calculs inutiles, stale values ou déclenchement d’effets involontaires.

Le tableau de dépendances

Un function component est appelé/rendu à chaque fois que l’une de ses propriétés (ou que son état) est modifié. Cela signifierait que, dans le cas de notre dernier exemple, le hook useEffect (et donc son callback)

useEffect(() => {
  const timerID = setInterval(() => setDate(new Date()), 1000);
  return () => clearInterval(timerID);
}, []);

seraient exécutés à chaque fois que date ou name changeraient de valeur.
Pour s’économiser ces exécutions inutiles à chaque rendu, un second argument optionnel de type Array peut être passé ; cet argument, souvent appelé dependency array, permet d’indiquer à React d’exécuter ce callback si et seulement si l’une des variables de ce tableau est différente depuis le dernier rendu (via une comparaison !==). Par extension, comme dans notre exemple, un tableau vide permettra d’exécuter le callback uniquement lorsque le composant sera monté : un équivalent de la méthode de cycle de vie componentDidMount.

La règle de linter exhaustive-deps se charge d’émettre des warnings lorsque des dépendances seront oubliées, ces dépendances étant toutes des variables modifiables entre deux rendus du composant et utilisées dans le hook. Ces dépendances sont attendues et nécessaires car, sans elles, le callback du hook risquerait de s’exécuter avec une valeur précédente de la dépendance. Seules les dépendances indiquées dans le callback et les références React auront leurs valeurs actualisées lors de la prochaine exécution, celles absentes pointeront toujours sur celle de la première déclaration du composant sans être jamais actualisées.
Il est possible de désactiver cet avertissement mais vivement recommandé de ne pas le faire, la réponse de Dan Abramov à ce sujet fait référence.

Cette exécution à la mise à jour d’une des dépendances du tableau est l’une des principales différences avec les méthodes de cycle de vie des composants de classe : au lieu d’avoir accès à toutes les valeurs précédentes et mises à jour des propriétés et de l’état du composant à chaque appel de componentDidUpdate (qui obligeait le plus souvent à déclarer de nombreux blocs de conditions “si la valeur précédente est différente de celle mise à jour” dans une unique méthode de cycle de vie), nous pouvons ne jouer plus que sur une exécution du callback à la condition “si au moins une des valeur du tableau de dépendance est différente par rapport au dernier cycle de rendu”. Cette contrainte oblige/permet de séparer la logique soumise à ces conditions dans des effets distincts, apportant de la lisibilité au code, mais rendant plus difficile l’exécution pour des conditions complexes exclusivement possibles par l’accès à prevState et prevProps.

En revanche, contrairement aux méthodes de cycle de vie de classe, nous pouvons exécuter un effet (un callback défini à la racine du composant via useEffect) à la mise à jour de n’importe quelle variable disponible dans la méthode de rendu du composant. Même si ces variables sont presque nécessairement dérivées de propriétés ou d’états du composant, cela peut s’avérer utile si ce dérivé reste strictement égal (===) à sa valeur de rendu précédent alors que les variables qui le composent sont mises à jour puisque cela évitera une exécution d’effet.

useEffect mais aussi useCallback et useMemo acceptent ce tableau de dépendances en second argument. Il est considéré optionnel mais l’omettre reviendrait à demander le recalcul de cette variable à chaque rendu, annulant leur utilité (useEffect ne s’exécutant qu’après la phase de rendu du composant, une absence de tableau de dépendance permettrait de retarder son appel après la mise à jour de l’interface : pratique pour garder une UI sans ralentissement, useLayoutEffect en revanche permet une exécution avant rendu).

Nettoyer au démontage

Dans notre dernier exemple de code, nous pouvons voir que le callback passé à useEffect retourne un autre callback qui se charge de supprimer l’intervalle lancé dans l’effet. Ce callback sera appelé lorsque le hook sera mis à jour, soit à la modification d’une dépendance ou, dans notre cas, au démontage du composant (il peut être vu comme un équivalent de la méthode de cycle de vie componentWillUnmount).

Cette pratique nommée cleanup permet en particulier de gérer le nettoyage des Timeouts, Intervals et EventListeners qui, sans, continueraient à exister alors que de nouveaux seraient ajoutés aux exécutions suivantes de l’effet. Cela aurait pour défaut d’exécuter des reducers d’états à partir de variables ayant mutées ou sur des composants démontés.

Le principe de faire retourner à notre instance de callback d’effet sa propre méthode de nettoyage permet entre autres d’y déclarer des variables qui lui seront propres (comme des identifiants d’Interval ou de Timeout) pour les annuler par la suite.

Cela est assez différent de l’usage que l’on avait des classes où les seuls “nettoyages” nécessaires étant souvent gérés dans les méthodes de cycle de vue du composant. Malgré une impression de complexification de gestion du composant, cette forme d’appel permet néanmoins de garder toute la logique propre à l’effet dans l’unique effet (plutôt que le répartir dans des méthodes de cycle de vie différentes).

Nouvel usage des références

Il est parfois nécessaire d’accéder au DOM non virtuel, notamment à la référence d’un élément du DOM contenu dans notre composant et sur lequel nous voudrions par exemple exécuter un focus(). Pour cela, React nous permet d’utiliser des références à des objet du DOM qui resteront stables entre les rendus à l’aide de React.createRef() et désormais son équivalent hooked : useRef().

Une référence est simplement un objet possédant une propriété current qui permet d’accéder à la valeur référencée : même si current est réassignée à une nouvelle valeur, nous pourrons toujours la lire en passant par sa référence (l’objet retourné par useRef).

Cette fonctionnalité était principalement indiquée pour accéder à un élément du DOM ou React, mais puisque ce n’est qu’un simple objet {current} elle se révèle très utile dans nos function components, permettant de définir et faire évoluer une variable dans notre composant mais hors de son scope de rendu (il ne faut cependant surtout pas qu’une valeur contenue dans une référence ait un impact sur le rendu de l’UI, car React n’aura aucun moyen de savoir si elle est mise à jour).
Elle pallie en particulier les problèmes logiques que peuvent causer certains callbacks d’évènements, par exemple pour garder l’identifiant d’intervalles nécessitant d’être interrompus à l’extérieur de l’effet(cf. doc React) les appelant.

Consommer un contexte

Les contextes React permettent de partager une valeur entre de multiples composants à travers l’arborescence de l’application sans avoir à les faire transiter entre les composants intermédiaires à l’aide de propriétés. Un usage typique serait de contenir les informations du thème de l’application ou encore de l’utilisateur connecté.

Se passer d’avoir à faire transiter une information sur les propriétés des composants situés entres celui possédant la valeur et le petit petit (petit) enfant le lisant était précédemment possible à l’aide des Context.Provider et Context.Consumer. Pour reprendre un exemple modifié de la documentation qui montre son usage pour transmettre une couleur de thème fourni à la racine de l’application via un contexte et lu par les deux types de composants (ici sans Hook) :

const ThemeContext = React.createContext('light');

const App = () => (
  <ThemeContext.Provider value="dark">
    <ThemedClassComponent />
    <ThemedFunctionComponent>
  </ThemeContext.Provider>
);

class ThemedClassComponent extends React.Component {
  static contextType = ThemeContext;
  render() {
    return <> We're using this theme : {this.context} </>;
  }
}

function ThemedFunctionComponent(){
  return(
    <ThemeContext.Consumer>
      {value => "We're using this theme : " + value}
    </ThemeContext.Consumer>
  )
}

Avec des class components, et pour simplifier l’accès au contexte, nous utiliserions un HOC afin de “wrapper” un composant cible avec un Context.Consumer pour qu’il accède au contexte directement par l’une de ses props.

function withTheme(WrappedComponent) {
  return class extends React.Component {
    render() {
      return (
        <ThemeContext.Consumer>
          {(theme) => <WrappedComponent theme={theme} {...this.props} />}
        </ThemeContext.Consumer>
      );
    }
  };
}

Le hook useContext permet de récupérer la valeur d’un contexte directement depuis le composant, simplifiant grandement son accès et nous permettant (si lire le thème depuis un composant plutôt qu’une propriété n’est pas un problème) de nous passer d’un HOC withContext. Cela revient en somme à :

const ThemeContext = React.createContext('light');

const App = () => (
  <ThemeContext.Provider value="dark">
    <ThemedFunctionComponent>
  </ThemeContext.Provider>
);

function ThemedFunctionComponent(){
  const theme = useContext(ThemeContext);
  return <>We're using this theme : {theme}</>;
}

Dernière notes

Memoized des props

Lorsque l’on souhaite faire remonter une information à un composant parent on passe par une propriété contenant un callback qui permettra de faire remonter la valeur via ses arguments. Ce callback sera appelé sous certaines conditions, comme la mise à jour d’un état du composant.
Lorsque ce dernier est hooked cela pourrait se traduire par l’utilisation d’un useEffect appelé à chaque modification de l’état remonté, mais aussi du callback. Cela se traduirait ainsi:

const Component = ({ onUpdate }) => {
  const [state, setState] = useState({});

  useEffect(() => {
    onUpdate(state));
  }, [state, onUpdate]); // on doit passer onUpdate comme dépendance, cela implique que l'effet sera appelé si sa référence venait à changer

  return (
    <>...</>
  );
};

Cette configuration nous oblige à memoized le callback passé à notre propriété pour ne pas le redéclarer à chaque rendu (car cela aurait pour effet de toujours le faire pointer vers une nouvelle référence et donc de réexécuter le useEffect qui le contient dans son tableau de dépendance).

Cela implique donc d’avoir la main sur le callback passé en propriété et, si celui-ci provient d’une librairie externe, que celle-ci soit développée avec cette problématique en tête ! (Soit que les callbacks passés soient memoized)

Dans notre exemple, le choix d’un useEffect aurait pu être évité en appelant par exemple directement le callback là où l’état du composant doit être mis à jour ou en gérant l’état dans le composant parent, mais certains cas peuvent rendre ces deux options non optimales :

  1. Si nous devions mettre à jour l’état à de multiple endroits dans notre composant cela nous obligerait à faire le lien manuellement à chaque fois
  2. Gérer un état dans le parent n’est parfois pas possible ou complexe, il existe encore le cas où cet état en interne est d’une forme plus complexe/riche que la valeur que nous souhaitons remonter au parent

Penser Hook apporte des contraintes

Passer à des function components utilisant des hooks implique que ces fonctions seront appelées à chaque rendu du composant et que nous devrons nous servir des hooks pour sortir certains aspects logique de ce rendu et ne les exécuter/calculer que lorsque certaines variables seront mises à jour. Cette simple logique apporte des contraintes fortes qui nous obligerons à faire des choix guidés sur la manière dont nos composants seront bâtis et communiqueront entre-eux.

Bien que cela puisse-t-être perçu comme une regression de nos libertés vis à vis de l’usage de React comme bibliothèque, on peut également considérer ces contraintes comme un atout permettant d’éviter l’éparpillement des logiques et façons de résoudre un problème (une sorte d’opinionated code formatter mais pour l’architecture logicielle). Ce que cela implique in-fine c’est une marche de plus à gravir pour les développeurs qui devront l’utiliser.

Et enfin

Un hook n’est pas un singleton

Si vous aviez l’habitude d’utiliser la notion de Service dans des MVC comme Angular, vous pourriez facilement les comparer aux Hooks React.

Ces derniers permettent en effet de partager un morceau de logique entre plusieurs composants. En revanche chaque appel à un Hook créera une nouvelle instance de ce Hook par instance de composants et il n’est pas possible (en suivant les règles prescrites par React) de créer l’équivalent Hook d’un Singleton. Nous pourrions instancier un service dans le scope global et s’y référer dans un composant ou Hook mais, si l’on souhaite rester exclusivement dans les fonctionnalités proposées par la bibliothèque, l’instanciation d’un contexte est ce qui s’approche le plus d’un singleton accessible par tous ses composants enfants.

Leur avantage réside dans la mutualisation de logique entre function components mais n’ajoute pas un nouvel outil de partage de données entre eux (celui ci reste de haut en bas).

Un hook est exécuté à chaque rendu

Bizarrement cela paraît évident à écrire mais c’est une erreur classique : à force de manipuler les hooks de base tels que useState ou useContext on pourrait en venir à penser qu’un hook custom ne s’exécutera qu’à certaines conditions. Or celui ci sera bien exécuté à chaque exécution du composant, soit chaque rendu ! C’est au développeur d’intégrer dans son hook la logique nécessaire pour éviter des calculs inutiles (à l’aide de useEffect, useMemo, useCallback ou useState).

Les function components n’héritent pas de PureComponent

Comme nous l’avions vu plus haut, l’un des grands avantages des hooks est de pouvoir se passer des composants de classe. Or, un class component doit hériter au choix de React.Component ou React.PureComponent (voir le extends de la classe WelcomeClock dans notre premier exemple). Ce dernier implémente par défaut la méthode de cycle de vie shouldComponentUpdate qui permet, dans le cas où le composant est de nouveau rendu par son parent, de ne rendre le composant que si l’une de ses propriétés a été mise à jour depuis le dernier cycle de rendu (prop !== prevProp).

React est connu pour la gestion d’un “DOM virtuel” qui lui permet de ne mettre à jour le “DOM réel” que lorsque cela est considéré nécessaire. Cette optimisation permet d’éviter de modifier inutilement le DOM, et cela même si le composant est rendu, mais ne signifie pas qu’un rendu de composant qui ne met pas à jours le DOM est sans coût sur les performances de l’application, surtout si celle ci est vaste. Il est généralement bienvenu de se passer d’exécution de code inutile et, malheureusement pour les Hooks, shouldComponentUpdate n’est pas utilisable sur les function components.

Comme nous l’avons vu, il est possible de conditionner l’exécution d’une grande partie de la logique grâce aux Hooks et, si cette optimisation vous est vraiment nécessaire, il existe la méthode React.memo : un HOC au comportement similaire à useMemo qui retourne un composant passé en argument de manière à ne le rendre que si l’une de ses propriétés a été modifiée (cela n’est valable que lorsque son parent demande un rendu et non lors d’un rendu suite à une mise à jour interne au composant, via son état ou encore une variable de contexte).

Conclusion

Lors de mes premiers essais avec React, je me souviens des coûteuses migrations d’un function component en class component lorsque celui-ci requérait un état, de la complexité des High Order Components presque incontournables pour garder des composants simples et réutilisables ou encore du sentiment que l’utilisation des contextes pouvait être améliorée.

Lorsque j’ai eu l’occasion de m’y replonger sur un nouveau projet, l’API Hooks était officiellement intégrée à React et j’ai été surpris par la simplicité de son usage. Encore aujourd’hui je reste bluffé par le nombre d’améliorations qu’elle apporte, la plus grande étant la possibilité de ne plus utiliser que des function components, bien plus lisibles que leur alternative sous forme de classe.

Je découvre encore aujourd’hui, à force d’utilisation, certaines limites de l’API dont j’ai pu parler sommairement dans ce billet. Mais je suis convaincu par leur intérêt dans React et cela en fait aujourd’hui mon framework JavaScript par défaut !


Publié

dans

par

Étiquettes :

Blog at WordPress.com.

En savoir plus sur enioka

Abonnez-vous pour poursuivre la lecture et avoir accès à l’ensemble des archives.

Continue reading