Les hooks React (2/3)

Hier on a parlé du state, dont on peut maintenant bénéficier dans les composant « fonction ». Peut-être cela n’a-t-il pas suffi à vous convaincre : après tout le gain n’est pas évident si vous êtes passé à Redux par exemple, le state, c’est un peu de l’histoire ancienne (on en reparlera dans le prochain article ;)). Abordons alors maintenant le cycle de vie des composants.

De manière générale, jusqu’ici on travaillait avec les méthodes suivantes des classes pour réagir à différentes étapes de la vie d’un élément :

  • constructor ou componentWillMount (déprécié) au moment de son initialisation ;
  • componentDidMount au moment de a première apparition dans la page ;
  • componentWillReceiveProps (déprécié), componentWillUpdate (déprécié) ou getSnapshotBeforeUpdate au moment où il va être mis à jour (nouveau state ou nouvelles props) ;
  • componentDidUpdate après sa mise à jour ;
  • componentWillUnmount au moment de sa destruction ;

Implémenter ces différentes méthodes a deux usages très courants :

  • Gérer des connexions, appels d’API, écoute d’événement… ;
  • Déclencher des effets de bord ;
  • Implémenter un wrapper autour d’un composant legacy ;

Il y a bien d’autres raisons de se « brancher » aux événements du cycle de vie du composant mais nous ne détaillerons que les deux premiers que nous généraliserons sous la notion d' »effet » (je parlerai du pattern « wrapper » autour d’un exemple concret dans un autre article).

Exemple d’effet : le bon vieux chat, version classe

Imaginons le composant « ChatRoom » qui a pour objet l’affichage d’une zone de discussion dans une « room » donnée. Il prend une prop indiquant la « room », et il a deux effets notables :

  • Lorsqu’il devient visible, il doit changer le titre de la page
  • Lorsqu’il est initialisé il doit ouvrir une connexion Websocket au serveur de chat
    • Lorsque la « room » est modifiée, il doit aussi recharger cette connexion

Implémentons déjà le changement de titre :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class ChatRoom extends React.Component {
  static defaultProps: {
    room: '#welcome',
  }
 
  originalTitle = document.title
 
  updateTitle() {
    document.title = `Room: ${this.props.room}`
  }
 
  componentDidMount() {
    this.updateTitle()
  }
 
  componentDidUpdate(props) {
    if (props.room !== this.props.room) {
      this.updateTitle()
    }
  }
 
  componentWillUnmount() {
    document.title = this.originalTitle
  }
 
  render() {
    return <div>TODO ChatRoom {this.props.room}</div>
  }
}

Pas de difficulté particulière : on modifie le titre à l’initialisation, au changement de room, et on a bien pensé à rétablir le titre d’origine lorsque le composant est démonté. Maintenant ajoutons la connexion WebSocket :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
class ChatRoom extends React.Component {
  static defaultProps: {
    room: '#welcome'
  }
 
  originalTitle = document.title
  socket = null
  state = { messages: [] }
 
  updateTitle() {
    document.title = `Room: ${this.props.room}`
  }
 
  addMessage = message => {
    this.setState(state => ({
      messages: [...state.messages, message]
    }))
  }
 
  connect() {
    this.socket = connectWebSocket(`/chat/${this.props.room}`)
    this.socket.on('message', this.addMessage)
  }
 
  disconnect() {
    this.socket.disconnect()
    this.setState({ messages: [] })
    this.socket = null
  }
 
  componentDidMount() {
    this.updateTitle()
    this.connect()
  }
 
  componentDidUpdate(props) {
    if (props.room !== this.props.room) {
      this.updateTitle()
      this.disconnect()
      this.connect()
    }
  }
 
  componentWillUnmount() {
    document.title = this.originalTitle
    this.disconnect()
  }
 
  render() {
    return (
      <div>
        TODO ChatRoom {this.props.room} with {this.state.messages.length}{' '}
        message(s)
      </div>
    )
  }
}

Évidemment, ça se complique mais on arrive encore à suivre, vaguement :

  • Au montage du composant, on connecte le websocket à l’URL dédiée à la room et on écoute les messages entrant ;
  • Au démontage, on pense bien à se déconnecter ;
  • Il faut aussi prévoir le changement de room, dans ce cas on se déconnecte et on se reconnecte ;

Évidemment, « en vrai », on ne ferait pas comme ça et ce serait probablement moins bourrin à l’update, mais de manière générale on commence à voir le schéma : au montage on déclenche un effet, au démontage on nettoie, et à la mise à jour on nettoie et on re-déclenche. Easy.

Isoler et réutiliser les effets

La première critique qu’on puisse faire c’est qu’il y a beaucoup de code mais surtout que les effets sont mélangés les uns aux autres : ici on a deux effets complètement distincts, mais chacun est éclaté dans les 3 même méthodes. Ce n’est ni agréable à lire, ni facile à maintenir, ni facile à implémenter initialement. Pour refactoriser ces effets afin de les isoler, pouvoir les réutiliser, les partager… il existait grosso modo deux méthodes jusqu’ici :

  • Les higher order components : une fonction retournant une version « améliorée » d’un composant
  • Les composants dédiés

On va ici voir la deuxième méthode car la première est un peu plus complexe, mais niveau quantité de code c’est similaire. On va donc créer, sur le modèle de ce que fait le module populaire react-helmet, un composant dédié à la gestion du titre de page :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class PageTitle extends React.Component {
  static defaultProps = {
    value: null
  }
 
  originalTitle = document.title
 
  updateTitle() {
    document.title = this.props.value || this.originalTitle
  }
 
  componentDidMount() {
    this.updateTitle()
  }
 
  componentDidUpdate(props) {
    if (this.props.value !== props.value) {
      this.updateTitle()
    }
  }
 
  componentWillUnmount() {
    document.title = this.originalTitle
  }
 
  render() {
    return null
  }
}

Une fois cette fonction disponible on pourra ainsi améliorer notre composant (on a retiré tout ce qui concernait la gestion du titre, et simplement ajouté une référence à PageTitle dans le rendu) :

31
32
33
34
35
36
37
38
39
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
79
80
class ChatRoom extends React.Component {
  defaultProps: {
    room: '#welcome'
  }
 
  socket = null
  state = { messages: [] }
 
  addMessage = message => {
    this.setState(state => ({
      messages: [...state.messages, message]
    }))
  }
 
  connect() {
    this.socket = connectWebSocket(`/chat/${this.props.room}`)
    this.socket.on('message', this.addMessage)
  }
 
  disconnect() {
    this.socket.disconnect()
    this.setState({ messages: [] })
    this.socket = null
  }
 
  componentDidMount() {
    this.connect()
  }
 
  componentDidUpdate(props) {
    if (props.room !== this.props.room) {
      this.disconnect()
      this.connect()
    }
  }
 
  componentWillUnmount() {
    this.disconnect()
  }
 
  render() {
    return (
      <div>
        <pagetitle value="{`Room:" ${this.props.room}`}=""></pagetitle>
        TODO ChatRoom {this.props.room} with {this.state.messages.length}{' '}
        message(s)
      </div>
    )
  }
}

Ainsi quand le composant ChatRoom voit sa prop modifiée, il sera re-rendu, provoquant une éventuelle mise à jour des props de PageTitle et ainsi la modification du titre de la page. Ça fonctionne bien.

Et maintenant, la version hooks avec useEffect

On a vu comment ça se passait dans le monde des classes, jusqu’ici lorsqu’on voulait gérer des effets on n’avait aucune autre option, il fallait jouer des méthodes du cycle de vie. Mais maintenant on a les hooks, incluant mon préféré : useEffect. Celui-ci prend deux paramètres :

  • Une fonction dont le rôle est d’appliquer les effets attendus ;
    • Cette fonction peut retourner une autre fonction dont le rôle sera de nettoyer (typiquement le disconnect après le connect) ;
  • Un tableau des dépendances (qui fonctionne exactement sur le même modèle que useCallback et useMemo) pour n’exécuter l’effet que si une des dépendances a changé de valeur ;
    • Si on ne passe aucune valeur, l’effet sera appliqué au montage ET à chaque mise à jour ;
    • Si on passe un tableau vide, l’effet ne sera applique qu’au montage ;

On pourra tenter de réécrire notre composant PageTitle de cette manière :

1
2
3
4
5
6
7
8
const PageTitle = ({ value = null }) =&gt; {
  // Update document's title each time props.value changes
  React.useEffect(() => {
    document.title = value
  }, [value])
 
  return null
}

Mais il manque la partie « nettoyage », sauf qu’on est un peu embêté pour l’écrire car on ne sait pas trop où mettre la copie du titre d’origine… En variable locale à la fonction n’a aucun sens puisqu’à chaque rendu elle sera écrasée, et en variable au-dessus ça n’a aucun sens non plus puisque toutes les instances de PageTitle partageraient alors la même valeur et il y aurait ainsi des interactions non désirées…

Conserver des données entre deux rendus avec useRef

Ce hook va prendre en paramètre une valeur initiale, et retourner une référence unique vers un objet conservée tout au long de la vie du composant. Cet objet a une propriété current contenant sa valeur actuelle. L’objectif initial est bien entendu d’être utilisé avec les refs React, mais on l’utilise surtout pour remplacer les membres d’instance de classe (pour la même raison qu’on avait utilisé useCallback dans l’article précédent). Attention car ce hook ne prend pas de tableau de dépendances en paramètres et il ne sera donc jamais redéfini pendant la vie du composant. Ainsi la valeur qu’on avait mise de côté dans un membre d’instance this.originalTitle va pouvoir être gérée via useRef :

1
2
3
4
5
6
7
8
9
10
11
const PageTitle = ({ value = null }) =&gt; {
  const originalTitle = React.useRef(document.title)
  // Update document's title each time props.value changes
  React.useEffect(() => {
    document.title = value
    // Restore original title on cleanup
    return () => document.title = originalTitle.current
  }, [value])
 
  return null
}

Même avec les commentaires, ça reste deux fois plus court que la version classe. Cela dit attention car ce n’est pas totalement équivalent : en effet à chaque mise à jour du composant (via ses props), l’effet sera réappliqué après nettoyage. Donc à chaque mise à jour on restaure le titre avant de le remodifier ! Ici ça n’a évidemment aucune importance, mais si ça en avait il faudrait prévoir un effet qui ne s’applique qu’au montage/démontage, et un autre pour les mises à jour, par exemple :

1
2
3
4
5
6
7
8
9
10
11
12
13
const PageTitle = ({ value = null }) =&gt; {
  // Restore original title on unmount
  const originalTitle = React.useRef(document.title)
  React.useEffect(() => {
    return () => document.title = originalTitle.current
  })
  // Update document's title each time props.value changes
  React.useEffect(() => {
    document.title = value
  }, [value])
 
  return null
}

Réutiliser les effets en écrivant ses propres hooks personnalisés

L’isolation des effets est acquise d’office : on aura un (ou plusieurs) hooks pour gérer le titre, d’autres pour gérer le websocket, et tout ça ne sera pas entremêlé. C’est un gain net mais malgré tout on va peut-être vouloir partager des effets réutilisables. Par exemple on a vu le besoin d’appeler une fonction au démontage, on pourrait écrire un hook personnalisé qui fasse ça ? Aucune difficulté, c’est une simple fonction que par convention on préfixe par use et qui peut faire appel à d’autres hooks :

1
2
3
4
5
const useUnmount = fn => {
  // no effect, just return cleanup function
  // empty deps = apply only at mount/unmount
  React.useEffect(() => fn, [])
}

Ce genre de refactorisation permet d’avoir un code plus clair. On pourra également réécrire notre effet sur le titre de la page sous forme de hook :

7
8
9
10
11
12
13
14
15
16
const usePageTitle = value => {
  React.useDebugValue(value)
  // Restore original title on unmount
  const originalTitle = React.useRef(document.title)
  useUnmount(() => (document.title = originalTitle.current))
  // Update document's title each time props.value changes
  React.useEffect(() => {
    document.title = value
  }, [value])
}

De l’intérêt de useDebugValue

Au passage, l’utilisation de React.useDebugValue permet d’améliorer l’expérience de débuggage lorsqu’on utilise les « React DevTools ». Voici ce qu’on obtient sans « debug value » :

On voit le nom du hook mais pas d’information supplémentaire

Et avec « debug value » :

Le bon vieux chat, version hooks

Maintenant qu’on a tout ce qu’il faut, on peut décrire tous nos effets sous forme de hooks  dédiés. On a déjà fait la gestion du titre de page, maintenant gérons notre websocket. Là c’est un peu plus compliqué car il s’agit d’un hook qui a un input mais également un output ! De manière générale quand vous voudrez refactoriser un effet sous forme de hook dédié il faudra se poser la question :

  • L’input = les arguments de la fonction = tout ce dont l’effet a besoin pour fonctionner ;
    • ici, c’est la room ;
  • L’output = les valeurs de retour de la fonction = tout ce que l’effet produit comme valeur et dont le composant peut avoir l’usage ;
    • ici, ce sont les messages ;
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const useChatSocket = room => {
  React.useDebugValue(room)
 
  const [messages, setMessages] = React.useState([])
 
  React.useEffect(() => {
    // Connect on mount or room change
    const socket = connectWebsocket(`/chat/${room}`)
    socket.on('message', message => {
      setMessages(m => [...m, message])
    })
    // Disconnect on unmount or room change
    return () => {
      setMessages([])
      socket.disconnect()
    }
  }, [room])
 
  return messages
}

Toute la gestion des messages du chat tient dans cette quinzaine de lignes… Un détail intéressant à noter : le setter retourné par useState permet tout comme setState d’être appelé avec une fonction de mise à jour, qui prendra alors en paramètre la valeur précédente du state. On utilise ici cette fonctionnalité afin de ne pas avoir à inclure messages dans les dépendances de l’effet (on n’a pas envie de se reconnecter à chaque fois qu’un message arrive), tout en étant sûr que l’ajout de message se fera avec la bonne liste de départ (on n’a pas non plus envie de repartir de la liste d’origine à chaque fois). Pas d’inquiétude, les règles eslint nous guident bien sur ce genre de détails :

3 solutions présentées : inclure la variable en dépendance, carrément supprimer le tableau de dépendance, ou bien utiliser la forme fonctionnelle du setter. Le choix est assez simple étant donnée les conséquences de chacun.

Et enfin, voici notre composant dans sa version finale :

39
40
41
42
43
44
45
46
47
48
const ChatRoom = ({ room = '#welcome' }) => {
  usePageTitle(`Room: ${room}`)
  const messages = useChatSocket(room)
 
  return (
    <div>
      TODO ChatRoom {room} with {messages.length} message(s)
    </div>
  )
}

Au final on obtient :

  • 2 effets clairement identifiés, séparés, et réutilisables ;
  • Un composant final trivial (il l’aurait aussi été en version classe, c’est surtout l’écriture des composants à effet de bord à base de higher order component ou de render prop qui aurait été bien plus complexe) ;
  • Un code bien plus court, malgré les commentaires et le découpages en effets intermédiaires ;

Conclusion

On a vu comment les hooks pouvaient nous aider à implémenter facilement des effets réutilisables ensuite par tous nos composants. On voit que rien qu’en mixant useEffect et useState, accompagnés de useCallback, useMemo, et useRef pour le maintien de références entre deux rendus, on peut déjà couvrir l’essentiel des besoins. Personnellement c’est vraiment useEffect qui m’a convaincu de l’intérêt des hooks. Si les autres comme useState ou useContextpermettent d’alléger le code, ici on atteint un autre niveau en terme d’organisation du code et on répond à une vraie problématique qui se posait avec les classes tant il était malaisé d’écrire des effets isolées et réutilisables.

Dans le prochain et dernier article de cette série nous explorerons la gestion des contextes pour faire la nique à Redux, et (spoiler) je concluerai de manière tout-à-fait impartiale à la supériorité enfin obtenue des fonctions et des hooks sur les classes dans React 🙂


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.

2 réflexions sur « Les hooks React (2/3) »

  1. Ping : Les hooks React (1/3) | naholyr.fr

  2. Ping : Les hooks React (3/3) | naholyr.fr

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.