Intermédiaire

React Hooks par l’exemple

Depuis la version 16.8, les Hooks ont été introduits dans React pour favoriser l'utilisation des Function Components.

Pour rappel, on peut créer un composant React en utilisant une fonction ou une classe.

La première solution (Function Component) est la plus simple, néanmoins elle ne permettait pas autant de possibilités que la seconde (Class Component), notamment lors de la déclaration d’un état ou la gestion du cycle de vie.

Les Hooks apportent une solution à ce problème.

Qu’est ce qu’un Hook ?

C’est une fonction permettant d’interagir avec les fonctionnalités de React (state, lifecycle, context, ref) dans un Function Component.

Avant, nous devions définir une classe pour contrôler la valeur d'un champ texte :

class MyInput extends React.Component { 
  constructor(props) { 
    super(props); 
    this.state = {name: ''}; 
    this.handleChangeName = this.handleChangeName.bind(this); 
  } 
  handleChangeName(e) { 
    this.setState({ name: e.target.value }); 
  }
  render() { 
    return <input type="text" value={this.state.name} onChange={this.handleChangeName}/>;
  } 
}

Désormais, avec le Hook useState, on peut obtenir le même résultat en utilisant un Function Component :

import React, { useState } from 'react'; 

function MyInput() { 
  const [name, setName] = useState(''); 
  const handleChangeName = e => setName(e.target.value); 
  return <input type="text" value={name} onChange={handleChangeName}/>; 
}

useState définit une variable appelée state variable dont sa valeur pourra évoluer au cours du temps. Elle correspondrait à this.state.name au sein d'une classe.

useState prend la valeur d'initialisation en paramètre et retourne un tableau à deux éléments. Le premier correspond à la valeur de la variable, et le second à une fonction pour pouvoir la modifier.

Comme useState est exécutée à chaque rendu du composant, le paramètre passé à la fonction est utilisé uniquement lors du premier rendu.

Quelles sont les règles à suivre pour utiliser un Hook ?

  • Un Hook doit être appelé depuis un Function Component ou un Custom Hook (il ne peut pas être utilisé dans une classe)
  • Son nom doit commencer par le mot-clé use
  • Il ne doit pas être exécuté à l’intérieur d’une condition ou d’une boucle

Nous reviendrons sur ces points plus tard.

useState et useEffect

useState et useEffect sont les deux principaux hooks mis à disposition par React.

useState peut être appelée plusieurs fois pour créer différentes variables. Lorsqu'il s'agit d'un objet, la fonction de modification retournée par useState le remplacera en intégralité, il n’y aura pas de merge comme c'était le cas avec this.setState.

Pour appeler un webservice, écouter un évènement ou manipuler le DOM, il faudra utiliser le hook useEffect. Il permet de définir des fonctions à "effet de bord" qui seront exécutées de façon asynchrone après le rendu du composant.

useEffect est une alternative aux méthodes du cycle de vie : componentDidMount, componentDidUpdate et componentWillUnmount.

Un exemple simple

Ci-dessous un exemple dans lequel le titre de la page évolue en même temps que la valeur d'un champ texte :

function Welcome() { 
  const [name, setName] = useState('John'); 

  function handleChangeName(e) { 
    setName(e.target.value); 
  }

  useEffect(() => { 
    document.title = `Welcome ${name}`; 
  }); 
  
  return <input type="text" value={name} onChange={handleChangeName}/>;
}

Optimisation

useEffect accepte un tableau comme second paramètre dans un but d'optimisation. La fonction effet sera exécutée uniquement si une des valeurs du tableau a été modifiée depuis l'appel précédent.
React utilise la méthode Object.is pour détecter une modification, il s'agit donc d'une comparaison de surface des valeurs du tableau.

On peut mettre à jour le code précédent pour jouer l’effet uniquement lors de la modification de la variable name.

useEffect(() => { 
  document.title = `Welcome ${name}`; 
}, [name]);

Lien de l'exemple

Nettoyage

Prenons un autre exemple dans lequel la date courante devra s'afficher avec un rafraîchissement toutes les secondes.

Voici une première version sans l’utilisation des Hooks :

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

  updateDate() { 
    this.setState({ date: new Date() }); 
  } 

  componentDidMount() { 
    this.intervalId = setInterval(() => this.updateDate(), 1000); 
  } 
  
  componentWillUnmount() { 
    clearInterval(this.intervalId);
  } 

  render() { 
    return <p>Nous sommes le {this.state.date.toLocaleString("fr-FR")}</p>; 
  } 
}

Voici une autre version en utilisant les hooks useState et useEffect :

function App() { 
  const [date, setDate] = useState(new Date()); 
  
  useEffect(() => { 
    console.log('effect'); 
    const intervalId = setInterval(() => setDate(new Date()), 1000);

    return function() { 
      console.log('cleanup effect'); 
      clearInterval(intervalId); 
    };
  }); 
  
  console.log('render'); 
  return <p>Nous sommes le {date.toLocaleString("fr-FR")}</p>; 
}

La fonction effet peut facultativement retourner une fonction de "nettoyage" qui sera exécutée immédiatement avant la prochaine exécution de l’effet ainsi qu’au moment où le composant sera retiré du DOM.

L’analyse des logs précédents permet de mieux comprendre ce fonctionnement :

T = 0s (1er rendu)

render / effect

T ≈ 1s (2nd rendu)

render / cleanup effect / effect

T ≈ 2s (3ème rendu)

render / cleanup effect / effect

Attention aux erreurs


Les lecteurs attentifs auront remarqué que dans l'exemple précédent, on supprime et recrée inutilement un intervalle à chaque rendu du composant. Si un nouveau rendu est provoqué par un autre évènement que la modification de la date, le fonctionnement de l'application sera erroné.

Pour s'en persuader, ajoutons un bouton qui incrémente un compteur. À chaque clic, le composant sera rafraîchi, provoquant la désinscription à l’intervalle précédent puis la création d’un nouvel intervalle.

function App() {
  const [counter, setCounter] = useState(0);
  const [date, setDate] = useState(new Date());
 
  useEffect(() => {
    const interval = setInterval(() => setDate(new Date()), 1000);
 
    return function() {
      clearInterval(interval);
    }
  });
   
  return (
    <div>
      {`${counter} `} 
      <button onClick={() => setCounter(counter + 1)}>+1</button>
      <p>Nous sommes le {date.toLocaleString("fr-FR")}</p>
    </div>
  );
}

Si on clique rapidement sur le bouton, le rafraîchissement de la date ne sera plus effectif.
Pour corriger l’exemple précédent, il suffit de passer un tableau vide à useEffect pour forcer l’effet à être exécuté une seule fois.
Cela revient à définir componentDidMount et componentWillUnmount (sans le componentDidUpdate).

useEffect(() => { 
  const interval = setInterval(() => setDate(new Date()), 1000);

  return function() { 
    clearInterval(interval); 
  }; 
}, []);

Voici la version finale.

Le souci du détail

Ajoutons maintenant un bouton pour pouvoir stopper et relancer le rafraîchissement de la date.

function App () {
  const [play, setPlay] = useState(true);
  const [date, setDate] = useState(new Date());
 
  useEffect(() => {
    console.log('effect');
    if (play) {
      const interval = setInterval(() => setDate(new Date()), 1000);
 
      return function() {
        console.log('cleanup effect');
        clearInterval(interval);
      }
    }
  }, [play]);
 
  function handleClick() {
    setPlay(!play);
    setDate(new Date());
  }
     
  console.log('render');
  return (
    <div>
      <button onClick={handleClick}>
        {play ? 'Pause' : 'Play'}
      </button>
      <p>
        Nous sommes le {date.toLocaleString("fr-FR")}
      </p>
    </div>
  );
}

Dans cet exemple, il pourrait être tentant d’englober l’appel à useEffect dans une condition. Le fonctionnement du composant serait cependant erroné.
D’une part parce qu’il est interdit d’appeler un Hook à l’intérieur d’une condition, d’autre part si la condition est fausse alors la fonction de nettoyage ne sera pas exécutée, l’intervalle ne sera donc pas supprimé lors du clic sur pause.
L’utilisation d’une condition doit nécessairement s'effectuer à l’intérieur de la fonction effet.

La fonction handleClick modifie deux state variables. Lorsque plusieurs appels à des fonctions de modification sont présentes au sein d'un évènement géré par React (onClick, onChange, onFocus, etc...), alors la librairie les regroupera et les appliquera en une seule fois pour générer un seul rendu du composant.
On peut vérifier ce comportement en analysant les logs précédents, "render" sera affiché une seul fois lors du clic sur le bouton pause malgré la modification de plusieurs variables.

Si ces modifications sont effectuées dans un évènement qui n'est pas géré par React (par exemple la fonction de résolution d'une promesse), alors il y aura autant de rendus du composant que d'appels à une fonction de modification, comme on peut le voir sur l'exemple suivant :

function App() {
  const [var1, setVar1] = useState(0);
  const [var2, setVar2] = useState(0);
  const [var3, setVar3] = useState(0);

  useEffect(() => {
    new Promise(resolve => setTimeout(resolve, 5000))
    .then(() => {
      setVar1(1);
      setVar2(2);
      setVar3(3);
    })
    .catch(() => {});

  }, []);

  console.log("render");
  return <div>{`${var1} + ${var2} = ${var3}`}</div>;
}

Pour éviter ce comportement, on pourrait regrouper ces variables au sein d'un même objet :

useState({var1: 0, var2: 0, var3: 0})

Lors de la mise à jour de l'objet, React ne fera pas de merge comme c'était le cas avec la fonction setState. Vous pouvez utiliser l'opérateur de décomposition pour reproduire ce mécanisme :

const [obj, setObj] = useState({var1: 0, var2: 0, var3: 0});

function updateVar1(var1) {
  setObj(prevObj => ({...prevObj, var1}));
}

Vous remarquerez que j'ai utilisé une fonction pour mettre à jour la variable. Bien que non obligatoire, c'est une pratique à privilégier lorsque la nouvelle valeur dépend de la précédente.


useCallback

useCallback permet d'éviter la création inutile de nouvelles fonctions à chaque rendu du composant.

Reprenons l'exemple sur le rafraîchissement de la date dans lequel nous extrairons le bouton dans un nouveau composant :

const Button = React.memo(function({onClick, children}) {
  console.log('render Button');
  return <button onClick={onClick}>{children}</button>
});
 
function App() {
  const [play, setPlay] = useState(true);
  const [date, setDate] = useState(new Date());
 
  useEffect(() => {
    if (play) {
      const interval = setInterval(() => setDate(new Date()), 1000);
 
      return function() {
        clearInterval(interval);
      }
    }
  }, [play]);
 
  function handleClickPlay() { 
    setPlay(!play); 
    setDate(new Date()); 
  }
     
  return (
    <div>
      <Button onClick={handleClickPlay}>
        {play ? 'Pause' : 'Play'}
      </Button>
      <p>Nous sommes le {date.toLocaleString("fr-FR")}</p>
    </div>
  );
}

React.memo retourne un composant optimisé qui sera rafraîchi uniquement lors d'une modification de ses propriétés (voir la fin de l'article pour une explication plus détaillée). Pourtant, en visualisant les logs, on s’aperçoit qu’un rendu de App provoque systématiquement un rendu de Button. Cela est dû au fait que la fonction handleClickPlay est recréée à chaque fois.

Le hook useCallback permet de corriger ce problème.

const handleClickPlay = useCallback( 
  function() { 
    setPlay(!play); 
    setDate(new Date()); 
  }, 
  [play] 
);

useCallback retournera une nouvelle fonction uniquement lorsque la variable play sera modifiée, dans le cas inverse la fonction retournée sera la même que la fois précédente. Cela nous permet de conserver les avantages offerts par React.memo.

Lien de l'exemple

useReducer et useContext

Pour les amateurs de Redux, React a mis à disposition le hook useReducer. Il prend en paramètre le reducer et l'état initial et retourne un tableau contenant le state et la fonction dispatch.

useContext permet de récupérer un contexte créé avec React.createContext. Il propose une alternative à this.context que l'on utiliserait dans une classe.

const AppContext = React.createContext({});
 
const initialState = {
  play: true,
  date: new Date()
};
 
function reducer(state, action) {
  switch (action.type) {
    case 'TOGGLE_PLAY':
      return {
        ...state,
        date: new Date(),
        play: !state.play
      };
    case 'UPDATE_DATE':
      return {
        ...state,
        date: new Date()
    };
    default:
      return state;
    }
}
 
const PlayPauseButton = React.memo(function() {
  const {state, dispatch} = useContext(AppContext);
  console.log('render Button');
  return (
    <button onClick={() => dispatch({type: 'TOGGLE_PLAY'})}>
      {state.play ? 'Pause' : 'Play'}
    </button>
  );
});
 
function App() {
  const [counter, setCounter] = useState(0);
  const [state, dispatch] = useReducer(reducer, initialState);
 
  useEffect(() => {
    if (state.play) {
      const interval = setInterval(
        () => dispatch({type: 'UPDATE_DATE'}), 
        1000
       );
 
      return function() {
        clearInterval(interval);
      }
    }
  }, [state.play]);
 
  return (
    <AppContext.Provider value={{state, dispatch}}>
      <button onClick={() => setCounter(counter + 1)}>
        {`Incrémenter compteur (${counter})`}
      </button>
      <PlayPauseButton/>
      <p>Nous sommes le {state.date.toLocaleString("fr-FR")}</p>
    </AppContext.Provider>
  );
}

Dans l'exemple précédent, un nouvel objet est systématiquement passé à AppContext.Provider, cela provoque un rafraîchissement de PlayPauseButton à chaque rendu du composant App même si le state n'a pas été modifié (comme on peut le voir en cliquant sur le bouton d'incrémentation).
On perd l'intérêt apporté par React.memo. Nous verrons juste après le Hook useMemo pour remédier à ce problème.

Lien de l'exemple

useMemo

useMemo permet de mémoïser une valeur afin d'éviter un recalcul (potentiellement coûteux) à chaque rendu du composant.

const [a, setA] = useState('a');
const [b, setA] = useState('b');
const object = useMemo( 
  function computeObject() { 
    return { a, b }; 
  }, 
  [a, b]
);

La fonction computeObject sera exécutée uniquement lors d'une modification de a ou b. Dans le cas contraire useMemo retournera l'objet mémoïsé.

On utilisera useMemo dans un souci d'optimisation, pour éviter de faire un calcul complexe trop souvent ou de créer un nouvel objet à chaque rendu.

const PlayPauseButton = React.memo(function() {
  const {state, dispatch} = useContext(AppContext);
  console.log('render Button');
       
  return (
    <button onClick={() => dispatch({type: 'TOGGLE_PLAY'})}>
      {state.play ? 'Pause' : 'Play'}
    </button>
  );
});
 
function App() {
  const [counter, setCounter] = useState(0);
  const [state, dispatch] = useReducer(reducer, initialState);
 
  useEffect(() => {
    if (state.play) {
      const interval = setInterval(
        () => dispatch({type: 'UPDATE_DATE'}), 
        1000
       );
 
      return function() {
        clearInterval(interval);
      }
    }
  }, [state.play]);
 
  const contextValue = useMemo(
    () => ({state, dispatch}), 
    [state, dispatch]
  );
 
  return (
    <AppContext.Provider value={contextValue}>
      <button onClick={() => setCounter(counter + 1)}>
        {`Incrémenter compteur (${counter})`}
      </button>
      <PlayPauseButton/>
      <p>Nous sommes le {state.date.toLocaleString("fr-FR")}</p>
    </AppContext.Provider>
  );
}

Grâce à useMemo, l'objet correspondant à la valeur du contexte restera le même tant que le state n'aura pas été modifié.

Le clic sur le bouton d'incrémentation entraîne un rafraîchissement de App mais pas de PlayPauseButton. On conserve les avantages apportés par React.memo, même si dans cet exemple les gains sont très faibles.

Lien de l'exemple

useRef

useRef offre la possibilité de créer un objet contenant une propriété mutable nommée current. L'objet retourné par useRef conservera la même référence tout au long du cycle de vie du composant.

Grâce à useRef on peut ainsi référencer un élément du DOM mais également déclarer une variable d’instance comme on le ferait avec this.myVar dans une classe.

function App() {
  const [date, setDate] = useState(new Date());
  const btnRef = useRef(null);
  const intervalIdRef = useRef(null);
 
  useEffect(() => {
    const intervalId = setInterval(() => setDate(new Date()), 1000);
    intervalIdRef.current = intervalId;
 
    return function() {
      clearInterval(intervalId);
    }
  }, []);
 
  function handleClick() {
    clearInterval(intervalIdRef.current);
    btnRef.current.setAttribute("disabled", "disabled");
  }
 
  return (
    <>
      <button onClick={handleClick} ref={btnRef}>
        Stop
      </button>
      <p>Nous sommes le {date.toLocaleString("fr-FR")}</p>
    </>
  );
}

Ici, useRef est utilisée à la fois pour référencer le bouton HTML et pour créer une variable d'instance contenant l’identifiant de l’intervalle.

Lien de l'exemple

Custom Hooks

Les Custom Hooks permettent la création de Hooks personnalisés afin de regrouper du code par fonctionnalité et de le partager entre plusieurs composants.

Un Custom Hook est une fonction commençant par use dans laquelle on peut appeler d’autres Hooks.

function useInterval(timeout, getValue) {
  const [value, setValue] = useState(getValue);
 
  useEffect(() => {
    const intervalId = setInterval(
      () => setValue(getValue()), 
      timeout
    );
 
    return function() {
      clearInterval(intervalId);
    }
  }, []);
 
  return value;
}

const getCurrentDate = () => new Date();
 
function App() {
  const date = useInterval(1000, getCurrentDate);
 
  return <p>Nous sommes le {date.toLocaleString("fr-FR")}</p>;
}

Le custom Hook useInterval regroupe le code lié à la création d'un intervalle. Nous sommes libres de retourner la valeur que l'on souhaite depuis un custom Hook.
Notez également qu'on peut passer une fonction à useState pour déterminer la valeur initiale, c'est utile lorsque ce calcul est coûteux car la fonction sera exécutée uniquement lors du premier rendu.

Avant les hooks, on utilisait les patrons de conception HOC ou render props pour partager du code entre composants, voici deux exemples :

  • react-redux fournit une fonction connect qui retourne un Higher Order Component, autrement dit une fonction qui prend en paramètre un composant et qui retourne un composant Wrapper.
  • react-router définit un composant Route dont la propriété render sert à générer le rendu de la route.

Dans les deux cas, le partage de code entre composants s'effectue à l'aide d'un composant complémentaire, communément appelé Wrapper Component. Cela vient complexifier l'arborescence de nos composants.
Les custom Hooks permettent d'éviter ceci, le code en commun est contenu dans une fonction traditionnelle dont le nom commence par use.

Plusieurs librairies de Custom Hooks comme react-use, awesome-react-hooks, Collection of react hooks sont disponibles sur github.

Lien de l'exemple

Questions diverses

Pourquoi ne doit-on pas utiliser les Hooks dans une condition ou dans une boucle ?


React garde en mémoire l'état associé à chacun des Hooks, il se base ensuite sur l’ordre d’exécution pour associer le Hook à son état. Il est donc primordial d'appeler le même nombre de Hooks dans le même ordre lors de chaque rendu du composant.

useState n'a pas connaissance de la variable qu'elle doit gérer, c'est le développeur qui lui donne un nom cohérent lors de l'affectation par décomposition, React se sert de l'ordre d'exécution pour récupérer la valeur associée au hook.

Pour useEffect, c’est grâce à l’ordre d’exécution que React pourra jouer la fonction de nettoyage au bon moment (immédiatement avant la prochaine exécution de l'effet). Si le code lié à l'effet est soumis à une condition, cette dernière doit impérativement être présente dans l'effet, elle ne doit pas englober l'appel à useEffect.

En ce qui concerne l’exécution des Hooks dans une boucle, si elle possède un nombre constant d’itérations, il ne devrait y avoir de problème, cependant la documentation stipule formellement :

Don’t call Hooks inside loops, conditions, or nested functions.

Mieux vaut donc respecter cette règle pour éviter les bugs. Un linter a été créé pour signaler les éventuelles erreurs d’utilisation.

Pourquoi un Hook doit-il commencer par le mot-clé use ?


Tout simplement pour pourvoir l'identifier. Le linter peut ainsi repérer les appels aux Hooks dans votre code et détecter les erreurs.

Les composants à base de classes sont-ils obsolètes ?


Absolument pas. Les concepteurs de React encouragent l'utilisation des Hooks pour les nouveaux composants, mais il est tout à fait possible de continuer à déclarer des composants sous forme de classes. Les Hooks donnent le choix au développeur d'utiliser l'une de ces deux méthodes.
Les Class Components et les Function Components cohabitent parfaitement au sein d'un même projet.

Quel est l'équivalent de shouldComponentUpdate ?


L'utilisation de React.memo permet de contrôler le rafraîchissement d'un composant. Par défaut il donne le même résultat qu'un PureComponent. Un contrôle de surface des propriétés du composant est effectué, si elles n'ont pas été modifiées alors le composant ne sera pas rafraîchi.

React.memo accepte un deuxième argument afin de personnaliser la comparaison entre les propriétés du composant et ainsi de contrôler son rafraîchissement :

function areEqual(prevProps, nextProps) {
  /*
  renvoie true si le composant aura le même rendu (pas besoin de rafraîchissement), false si le composant doit être rafraîchi.
  */
}

C'est en fait l'inverse de la méthode shouldComponentUpdate.

Prenons le cas d'une Modal pour laquelle nous ne souhaiterions pas rafraîchir son contenu lorsqu'elle reste cachée :

function Modal({hidden, content}) {
   ...
}

React.memo(
  Modal, 
  (prevProps, nextProps) => prevProps.hidden && nextProps.hidden 
);

On pourrait également effectuer une comparaison des propriétés (shallowCompare) pour appliquer le comportement par défaut de React.memo lorsque la condition est fausse.
Dans la majorité des cas on utilisera React.memo pour obtenir le même comportement qu'un PureComponent.

Comment exécuter du code uniquement lors de la modification d'une propriété du composant (componentDidUpdate) ?


Le code présent dans un effet sera exécuté lors d'une mise à jour du composant mais également lors de son initialisation. Afin d'éviter ce dernier point, on utilisera useRef pour initialiser une variable d'instance à true, celle-ci passera à false juste après le premier rendu et conditionnera l'exécution du code cible :

function App({propA}) {
  const isFirstRender = useRef(true);
  useEffect(() => {
    if (isFirstRender.current) {
      isFirstRender.current = false;
    } else {
      // code à exécuter uniquement lors d'une modification de propsA
    }
  }, [propA]);
}

Conclusion

À travers ces exemples, on comprend désormais qu’un Function Component n’est plus synonyme de Stateless Component. Il n'est plus nécessaire de transformer une fonction en classe lorsqu'on souhaite ajouter un state ou utiliser le cycle de vie du composant.

Sans apporter de nouvelles fonctionnalités, les Hooks donnent la possibilité de choisir la manière de concevoir ses composants. L'utilisateur dispose d'une plus grande liberté sans être pénalisé par une lourde migration à entreprendre.

Les hooks sont aujourd'hui bien intégrés dans l'écosystème React. Des librairies majeures telles que react-redux, react-router, react-apollo fournissent des custom hooks dans leur API.

Pour finir, la FAQ reste une excellente source d'information pour mieux comprendre leur fonctionnement.

Nombre de vue : 0

AJOUTER UN COMMENTAIRE