Les hooks React (3/3)

Dans ce dernier épisode nous allons étudier les contextes, comment leur accès est simplifié avec useContext et aborder la possibilité de se passer de Redux sans perdre en souplesse.

Les contextes : pourquoi et comment ?

L’API Context a un historique assez mouvementé sur React : le besoin étant de rendre disponible une valeur à travers toute l’arborescence des éléments, sans avoir à la passer explicitement d’enfant en enfant via les props, sans pour autant passer par une simple variable globale. Typiquement on déclare la valeur requise à la racine (store Redux, router React-Router, infos de localisation, thème, etc…) et seuls les composants qui en ont besoin la consomment.

La première version de cette API était d’abord non documentée, et ensuite avait l’inconvénient majeur de ne pas provoquer le re-rendu des « consommateurs » du contexte. Ainsi changer la localisation ou le thème ne provoque pas automatiquement la mise à jour des composants qui en ont besoin, ce qui impose des manipulations supplémentaires. Afin de répondre à cette problématique une nouvelle API basée sur des composants Provider et Consumer a été mise en place : l’intérêt principal de passer par des composants étant de provoquer un rendu des consommateurs lorsque le contexte change. Cette nouvelle API a également été l’occasion d’un nettoyage des mécaniques internes qui faisaient passer « magiquement » la valeur à travers l’arbre, mais ce nettoyage a pour conséquence que le contexte doit maintenant être déclaré et accessible explicitement au fournisseur comme au consommateur, nécessitant donc un singleton, ce qui peut poser problème lors du server-side rendering (ce n’est pas un vrai problème, comme on le verra dans un prochain article, mais ça nécessite une attention particulière).

createContext, Provider, Consumer

L’utilisation d’un contexte se fait en 3 étapes . Tout d’abord sa création avec React.createContext :

const MyContext = React.createContext(defaultValue)

La leur retournée est un objet avec deux propriétés Provider et Consumer qui sont tous deux des composants. Le premier sert à rendre disponible la valeur à ses enfants (récursivement) ainsi qu’à contrôler les modifications apportées à cette valeur. Ainsi pour « déclarer » un contexte et/ou changer sa valeur, on utilise Provider :

// Tous les enfants, petits-enfants, etc… de Child auront
// accès à la valeur
<mycontext .provider="" value="{newValue}">
  <child></child>
</mycontext>

Enfin, n’importe où dans l’arbre tant que c’est une descendance du Provider on peut utiliser le Consumer pour accéder à la valeur, en utilisant la technique de « render-prop » (ici en passant simplement une fonction en tant qu’enfant, qui sera appelée avec la valeur actuelle du contexte) :

<mycontext .consumer="">
  {value => (
    <strong>value = {value}</strong>
  )}
</mycontext>

Les contraintes et limites de cette API

Outre le fait qu’on doive se traîner un singleton, cette API n’est pas exempte de défauts. Le premier étant que les composants sont insuffisants pour accéder à la valeur du contexte hors de la fonction render(). Il est possible de souscrire à un contexte via le membre statique contextType :

class MyComponent extends React.Component {
  // on peut accéder à "this.context" dans n'importe quelle méthode
}
MyClass.contextType = MyContext;

Grosse limitation ici : on ne peut souscrire de cette manière qu’à un seul contexte. D’ailleurs même avec les composants il est assez pénible de consommer plusieurs contextes, voyez donc :

<context1 .consumer="">
  {value1 => (
    <context2 .consumer="">
      {value2 => (
        <context3 .consumer="">
          {value3 => (
            <div>{value1}, {value2}, {value3}</div>
          )}
        </context3>
      )}
    </context2>
  )}
</context1>

Ça pique. Ça rappelle un peu les heures les plus sombres de la pyramid of doom. Évidemment des modules permettront de simplifier ça, mais utiliser le hook useContext est aussi une bonne option.

Consommer un contexte avec useContext

Ce hook ultra simple prend en paramètre l’objet retourné par createContext et retourne sa valeur actuelle. Si le contexte change, il provoque évidemment une mise à jour du composant tout comme Consumer. Il permet ainsi de souscrire à plusieurs contextes sans imbrications :

const value1 = useContext(Context1)
const value2 = useContext(Context2)
const value3 = useContext(Context3)
return <div>{value1}, {value2}, {value3}</div>

On ne parle même pas de la réduction du code, on a compris que c’était systématique avec les composants « fonction » et les hooks. Ici, on a accès à une fonctionnalité que les classes ne permettent pas du tout : il devient possible d’accéder à plusieurs contextes dans les méthodes du cycle de vie. En effet avec les classes on ne peut déclarer qu’un static contextType unique, et c’est le seul moyen d’accéder à une valeur de contexte dans componentDidMount par exemple. Ici cette limitation disparaît, et on a donc le premier hook qui offre vraiment une fonctionnalité supplémentaire unique, inaccessible à l’API basée sur les classes 😀

Gérer son état global en contexte : se passer de Redux ?

C’est ce que fait Redux en interne : il initialise un contexte dans lequel le state est conservé, mis à jour lors des dispatch et consommé via le higher order component « connect« . La gestion du contexte est devenue vraiment plus simple aujourd’hui et l’utilisation d’un module tiers peut poser question. L’intérêt de Redux devient alors simplement d’imposer une architecture à base d’actions, reducer, state immutable et de brancher ça ensemble. Mais (spoiler !) il ne s’agit que d’une dérivation de userState qui plus est fournie en standard.

Une autre façon de gérer son state avec useReducer

Avant d’aller plus loin je vous présente le petit dernier de notre liste : useReducer prend en paramètre le reducer, le state initial, et retourne un couple composé du state actuel et de la fonction de dispatch. À noter que contrairement aux classes, et comme pour useState, le state n’est pas forcément un objet et peut être une simple valeur scalaire. Reprenons notre exemple de compteur :

Éditer dans JSBin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const reducer = (value, action) => {
  switch (action.type) {
    case 'increment':
      return value + 1
    default:
      return value
  }
}
 
const Button = ({ initialValue = 1 }) => {
  const [value, dispatch] = React.useReducer(reducer, initialValue)
  const increment = React.useCallback(() => dispatch({ type: 'increment' }))
 
  return (
    <button onClick={increment}>{value}</button> 
  )
}

Évidemment quand on utilise useReducer c’est généralement pour gérer un état plus complexe, et on aura donc à travailler sur un objet complexe plutôt que sur de simples valeurs scalaires. On peut ainsi garder nos concepts de reducer, nos action creators, et tout ce qui est vraiment intéressant dans Redux. Suffirait-il de simplement distribuer notre state via un contexte comme c’est déjà le cas aujourd’hui implicitement, et de cumuler ça avec useReducer pour se débarrasser de Redux ? Spoiler : oui.

Réimplémentons un Redux-like

Première étape : définir le state initial, le reducer (et donc les actions). Ce sont les concepts génériques dont on aura de toute façon besoin. On part d’une application qui gère une couleur et une valeur de compteur dans son état global :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const reducer = (state, action) => {
  switch (action.type) {
    case 'change-color':
      return { ...state, color: action.payload }
    case 'increment':
      return { ...state, value: state.value + (action.payload || 1) }
    default:
      return state
  }
}
 
const initialState = {
  value: 1,
  color: 'black'
}

Première étape : coller le state de notre application dans un contexte, et le lire dans les composants qui en ont besoin.

17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const AppContext = React.createContext()
 
// Button va lire le state depuis le contexte
const Button = () => {
  const { value } = React.useContext(AppContext)
  return <button>{value}</button>
}
 
// Le composant racine n'a qu'à le rendre disponible en
// fournissant le state initial
const App = () => {
  return (
    <appcontext .provider="" value="{initialState}">(Button est quelque-part dans cette arborescence)
    </appcontext>
  )
}

Facile ! Par contre pour mettre à jour le state c’est une autre paire de manche, on peut naturellement vouloir utiliser useReducer mais attention car ce hook gère un état local et nous on veut mettre à jour l’état global. Cela ne peut être fait que dans le composant qui utilise AppContext.Provider et c’est donc à App de gérer cet état, et la fonction dispatch sera donc locale à App. Or c’est Button qui a besoin de dispatcher… Comment passer cette fonction au composant descendant ? Et bien comme toutes les autres valeurs : via le contexte tout simplement. Ce problème est abordé dans la documentation “Updating context from a nested component” .

On va créer notre composant Provider qui remplit cette mission (ici j’ai choisi d’injecter le setter directement en propriété du contexte lui-même, comme c’est de toute façon un singleton je préfère polluer l’objet lui-même plutôt que le state avec cette valeur, ça servira aux PureComponent) et utilise useReducer pour gérer son état interne, il a juste en plus à exposer la fonction dispatch au reste de l’application :

21
22
23
24
25
26
27
28
29
30
31
32
33
const Provider = ({
  Context = AppContext,
  reducer,
  initialState,
  children
}) => {
  // L'état interne du provider = la valeur du contexte
  const [state, dispatch] = React.useReducer(reducer, initialState)
  // On injecte le setter dans le contexte
  Context.dispatch = dispatch
  // On expose le state à toute l'arborescence
  return <context .provider="" value="{state}">{children}</context>
}

Les composants internes n’ont donc plus besoin que de :

  • useContext(AppContext) pour lire l’état et être mis à jour lorsqu’il est modifié
  • AppContext.dispatch pour modifier l’état via le reducer

Comme on est bien élevé, on en fait tout de suite un hook pour éviter de répéter cette logique à chaque fois :

35
36
37
38
const useStore = (Context = AppContext) => [
  React.useContext(Context), // Valeur du state global
  Context.dispatch // Fonction dispatch définie par le Provider
]

C’est d’une simplicité confondante, et notre application complète aurait cette tête :

Éditer dans JSBin
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
const IncrButton = () => {
  const [state, dispatch] = useStore()
  const increment = React.useCallback(() => dispatch({ type: 'increment' }), [])
 
  return (
    <button onClick={increment} style={{ color: state.color }}>
      {state.value}
    </button>
  )
}
 
const ColorPicker = () => {
  const [state, dispatch] = useStore()
 
  const pick = React.useCallback(
    e => dispatch({ type: 'change-color', payload: e.target.value }),
    []
  )
 
  return (
    <select onChange={pick} value={state.color} style={{ color: state.color }}>
      <option value="green">Green</option>
      <option value="black">Black</option>
      <option value="red">Red</option>
    </select>
  )
}
 
const App = () => (
  <provider initialState={initialState} reducer={reducer}>
    <p>
      Color picker somewhere: <colorpicker></colorpicker>
    </p>
    <hr />
    <p>
      Button somewhere else in the tree: <incrbutton></incrbutton>
    </p>
  </provider>
)

Le Provider : 4 lignes de code ; le hook : 3 lignes. On n’a évidemment pas tout couvert, il manque mapStateToProps et mapActionsToProps mais peut-être peut-on s’en passer ? Pour le second c’est assez clair ; pour le premier en revanche c’est un peu moins trivial pour des raisons de performances.

Gros contexte multi-usages et performances

Mise à jour du contexte = update du composant qui appelle useContext. En l’état actuel je vais générer plein d’updates inutiles. Dans notre exemple lorsque je clique sur le bouton ça provoque un update du color picker, ce qui ne devrait pas arriver. Il y a plusieurs approches pour répondre à ce problème, qui sont abordées dans l’issue facebook/react#15156 :

  • Ne pas utiliser un seul contexte, mais un contexte distinct pour chaque valeur qui peut ne pas changer en même temps que les autres : c’est évidemment la solution la plus propre, en revanche en terme d’organisation de code ça peut en faire un gros paquet car même en découpant en contextes « fonctionnels » on aura toujours des composants qui n’utilisent qu’une portion et si on veut tout optimiser on va se retrouver invariablement avec un contexte par variable ;
  • Utiliser la mémoisation : les options 2 et 3 sont similaires mais l’option 2 travaille avec un objet (les props) quand l’option 3 va se baser sur un tableau (le tableau de dépendance de useMemo), même si au final ce sont les mêmes valeurs qui comptent.

L’option 2 nous dirigera naturellement vers un higher order component à la Redux. C’est la meilleure solution, mais l’objectif étant de se creuser la tête autour des hooks, je suis parti sur l’option 3. Voici nos composants optimisés pour ne pas régénérer leur arbre de rendu à chaque update qui ne les concerne pas :

Éditer dans JSBin (j’ai ajouté un petit témoin pour bien observer que le picker n’est plus mis à jour à chaque incrément)
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
const IncrButton = () => {
  const [state, dispatch] = useStore()
  const increment = React.useCallback(() => dispatch({ type: 'increment' }), [])
 
  return React.useMemo(() => (
    <button onClick={increment} style={{ color: state.color }}>
      {state.value}
    </button>
  ), [state.color, state.value])
}
 
const ColorPicker = () => {
  const [state, dispatch] = useStore()
  const pick = React.useCallback(
    e => dispatch({ type: 'change-color', payload: e.target.value }),
    []
  )
 
  return React.useMemo(() => (
    <select onChange={pick} value={state.color} style={{ color: state.color }}>
      <option value="green">Green</option>
      <option value="black">Black</option>
      <option value="red">Red</option>
    </select>
  ), [state.color])
}

Mission accomplie, mais on pourrait aller plus loin bien sûr : on pourrait complexifier notre hook pour lui faire faire les appels à useMemo et useCallback qui vont bien et éviter de devoir les répéter ; la principale difficulté viendra alors de la génération d’un tableau de dépendances correct : cette valeur dépend du composant car dans certaines situations la valeur finale du state locale dépend d’une combinaison du state global et des props locales ; de même pour les actions. Pour les curieux je suis allé un peu plus loin dans ce JSBin où je propose une implémentation de useStore qui devrait répondre à la grande majorité des besoins, et on voit que ça tient en une vingtaine de lignes. Ensuite l’utilisation dans les composants devient vraiment triviale. Je maintiens qu’un higher order component reste plus adapté (notamment pour la possibilité d’utiliser le système de PureComponent qui me semble plus digeste que l’appel à React.useMemo à l’intérieur du composant).

Cette implémentation va notamment plus loin que ce que j’ai pu voir jusqu’ici dans la plupart des articles (par exemple celui-ci) traitant de la question du useRedux en omettant la question des updates inutiles, alors qu’on a tous les outils pour faire bien 🙂

Alors on dégage Redux ?

Je pense qu’en effet beaucoup de projets pourraient tout simplement utiliser directement les contextes pour gérer leur état. Mais dans ce cas je ne m’embêterais carrément pas avec le pattern reducer/actions. À partir du moment par contre où on trouve que ce pattern apporte vraiment quelque-chose, Redux reste la bonne solution : son implémentation est très légère, et ce que vous gagnez à le supprimer est vraiment léger. Si vous réfléchissez à supprimer Redux, il faut que ce soit pour ne pas conserver le pattern reducer/actions, sinon autant le garder. Trouver des implémentations alternatives est un exercice formateur et amusant toutefois 😉

Conclusion

Les hooks fonctionnent bien, ils sont agréables à utiliser et efficaces. On peut vraiment dire aujourd’hui, sans mauvaise foi, qu’on n’a plus besoin d’utiliser les classes pour faire du React ! et connaissant mon amour pour this, vous imaginez la joie que ça me procure !

Il y a bien sûr des inconvénients, qui sont principalement de l’ordre du manque d’habitude : j’attire notamment toute votre attention sur les tableaux de dépendances, qui ne sont pas toujours évidents à gérer ; et la règle eslint react-hooks/exhaustive-deps est loin d’être inutile (elle est activée par défaut si vous utilisez create-react-app) même si elle est un peu « brute » et nous forcera à créer des tableaux à rallonge avec des valeurs dont on sait pourtant qu’elles ne changent jamais. En terme de maintenance, je vous conseille de ne pas désactiver cette règle, et de vous traîner ces tableaux trop longs plutôt que d’ignorer le warning. En effet, si quelques mois plus tard un junior vient intervenir sur le composant en ajoutant une prop passée à un callback en oubliant d’ajouter la dépendance au useCallback, vous serez vraiment contents qu’il ait eu un warning. C’est le genre de bug qui va être pénible à débusquer.

Il reste bien des choses à voir et je vous renvoie pour ça à la doc (RTFM !), mais pour conclure je dirais qu’il n’est évidemment pas utile de réécrire vos composants actuels (if it’s not broken, don’t fix it) ‑ éventuellement si vous avez des composants simples utilisant des méthodes dépréciées ça peut être une bonne occasion ‑ mais que vous risquez fort de gagner en quantité de code ainsi qu’en maintenabilité et en lisibilité en passant prudemment aux hooks pour vos prochains développements : c’est stable, plaisant, et déjà largement adopté par la communauté (useRedux, useReactRouter, useSocketIo, useToutCeQueTuVeux, c’est déjà là !) donc le risque est vraiment mesuré et la courbe d’apprentissage plutôt cool.


Sommaire de la série

  1. « Les hooks React (1/3) » où l’on parle des hooks en général et de useState et useCallback en particulier.
  2. « Les hooks React (2/3) » où l’on parle de la gestion des effets et de leur réutilisation sans pattern compliqué avec useEffect.
  3. « Les hooks React (3/3) » où l’on parle des contextes et où on essaie de faire du mal à Redux.

Laisser un commentaire

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.