Suis-moi sur les réseaux

Comment écrire useState avec useReducer 🌳

🗓 11 mars 2021

Aujourd’hui je vais te montrer un petit truc assez sympa que j’ai découvert grâce à un article de Kent C. Dodds. Comme tu l’as remarqué dans le titre, il est possible d’écrire notre propre useState grâce à useReducer.

L’article ci-dessus est plutôt explicatif et très bien fait donc je t’invite vivement à aller le lire après 😉

Résumé (TLDR;)

Pour les plus pressés 😏 et adeptes du Ctrl-C / Ctrl-V

import { useReducer } from 'react'

const reducer = (state, newState) =>
  typeof newState === 'function' ? newState(state) : newState

const initializer = initialValue =>
  typeof initialValue === 'function' ? initialValue() : initialValue

const useState = initialValue => useReducer(reducer, initialValue, initializer)

export default useState

useState - Rappel

Pour rappel, useState permet de gérer l’état d’un composant fonctionnel

const TextField = () => {
  const [value, setValue] = React.useState('')
  return <input value={value} onChange={evt => setValue(evt.target.value)} />
}

Il prend un argument optionnel qui permet d’initialiser la valeur de l’état et retourne un tableau contenant l’état (value) et la fonction de mise-à-jour de celui-ci (setValue)

💡

Tu peux nommer l’état et la fonction de mise à jour comme tu veux, par exemple banane et setBanane

La fonction de mise-à-jour de l’état prend à son tour un argument qui deviendra la prochaine valeur de l’état. A noter que cet argument peut prendre la forme d’une fonction (callback). Dans ce cas, le callback prend comme argument la valeur précédente de l’état.

const Counter = () => {
  const [count, setCount] = React.useState(0)
  const handleClick = () => {
    // le callback prend comme paramètre la valeur précédente de `count`
    const cb = prevCount => prevCount + 1
    setCount(cb)
  }
  return <p onClick={handleClick}>{count}</p>
}
💡

Cette façon de mettre à jour l’état avec un callback est une bonne pratique lorsque la valeur suivante dépend de la précédente

Enfin, il est possible aussi d’initialiser la valeur de l’état en mode lazy si l’initialisation prend beaucoup de temps. Je m’explique.

const Component = () => {
  const initialStateValue = veryExpensiveInit()
  const [state, setState] = useState(initialStateValue)
  return // .....
}

veryExpensiveInit est une fonction qui prend énormément de temps à exécuter. Cela a pour conséquence de ralentir le rendu du composant.

A chaque mise-à-jour et re-rendu du composant, initialStateValue est re-calculé même s’il n’est pas utilisé par useState puisqu’on n’a besoin de la valeur initiale qu’au tout premier rendu.

Vient alors l’initialisation en mode lazy. Il consiste à envoyer en paramètre à useState une fonction qui appelle veryExpensiveInit

React se charge ainsi d’appeler cette fonction uniquement si le composant est à son tout premier rendu.

const Component = () => {
  // `veryExpensiveInit` ne sera exécuté
  // que si le composant est affiché pour la première fois
  const [state, setState] = useState(() => veryExpensiveInit())
  return // .....
}

En résumé, si nous voulons ré-implémenter useState , les fonctionnalités à respecter sont

  • 📌initialisation et mise-à-jour de l’état avec une nouvelle valeur
  • 📌mise-à-jour de l’état avec une fonction callback
  • 📌initialisation en mode “lazy”

useReducer - Alternative à useState

useReducer est un hook qui permet aussi de gérer l’état d’un composant. Il est préférable de l’utiliser si le composant requiert une logique plus complexe.

Il reçoit deux arguments:

  • 📌une fonction reducer de la forme (state, action) => newState
  • 📌une valeur initiale de l’état

Et retourne un tableau de 2 éléments:

  • 📌la valeur de l’état
  • 📌la fonction de mise-à-jour de l’état que l’on appelle conventionnellement dispatch

Remarque

💡

action est l’argument que l’on passe à la fonction dispatch pour mettre à jour l’état

Exemple avec un Compteur (oui encore un enième compteur 😏)

import { useReducer } from 'react'

const reducer = (state, action) => {
  switch (action) {
    case 'INCREMENT':
      return state + 1
    case 'DECREMENT':
      return state - 1
    case 'RESET':
      return 0
    default:
      return state
  }
}

const Counter = () => {
  const [count, dispatch] = useReducer(reducer, 0)
  return (
    <main>
      <p>{count}</p>
      <button onClick={() => dispatch('INCREMENT')}>+</button>
      <button onClick={() => dispatch('DECREMENT')}>-</button>
      <button onClick={() => dispatch('RESET')}>Reset</button>
    </main>
  )
}

Implémenter useState avec useReducer

Initialisation et mise-à-jour de l’état

Avec ce que l’on sait de useReducer, il n’y a à priori pas beaucoup de difficultés à implémenter cette fonctionnalité.

import {useReducer} from 'react'

// j'ai renommé `action` en `newState`
// mais son nom n'a pas vraiment d'importance
// le plus important à retenir est que cet argument
// est celui que l'on fait passer au `reducer` grâce à `dispatch`
const reducer = (state, newState) => return newState

const useState = (initialValue) => {
  // j'ai renommé `dispatch` en `setState`
  const [state, setState] = useReducer(reducer, initialValue)
  return [state, setState]
}

En simplifiant, on a

const reducer = (state, newState) => newState
const useState = initialValue => useReducer(reducer, initialValue)

Mise-à-jour de l’état avec un callback

Il faut maintenant que la fonction dispatch (ou setState) ait la capacité de savoir si on passe une valeur ou une fonction en paramètre de celle-ci.

Pour cela, on teste tout simplement le type de action (ou newState)

  • 📌si c’est une fonction, on l’exécute en lui passant comme argument l’état précédent et on renvoie le résultat
  • 📌si c’est une valeur on la renvoie directement
- const reducer = (state, newState) => newState
+ const reducer = (state, newState) => typeof newState === 'function' ? newState(state) : newState
const reducer = (state, newState) =>
  typeof newState === 'function' ? newState(state) : newState
const useState = initialValue => useReducer(reducer, initialValue)

Initialisation de l’état en mode lazy ou “Lazy initialization”

La solution à celle-ci est moins évidente mais la clé à ce problème se trouve ici. Oui, useReducer peut recevoir un 3ème argument qui permet de faire une “lazy initalization”.

useReducer(reducerFunction, initialState, initializer)

Ce 3ème argument initializer est exécutée de la sorte

initializer(initialState)

Elle a été prévue exactement pour les mêmes raisons que useState. C’est-à-dire permettre au développeur d’exécuter une fonction d’initialisation, coûteuse en ressources, uniquement au premier appel de useReducer

// ce code
const initialValue = someProps
const initialState = veryExpensiveInit(initialValue)
const [state, dispatch] = useReducer(reducer, initialState)

// devient alors
const initialValue = someProps
const initializer = initialValueArg => veryExpensiveInit(initialValueArg)
const [state, dispatch] = useReducer(reducer, initialValue, initializer)

Dans notre cas, on veut savoir si l’argument reçu par notre useState et transféré à React.useReducer est une fonction ou une valeur. Pour cela on ajoute une fonction initializer et on fait cette vérification à l’intérieur de celle-ci.

const reducer = (state, newState) =>
  typeof newState === 'function' ? newState(state) : newState

const initializer = initialValue =>
  typeof initialValue === 'function' ? initialValue() : initialValue

const useState = initialValue => useReducer(reducer, initialValue, initializer)

Conclusion

Le but maintenant n’est pas de refaire la même chose dans ton code et de remplacer tous tes React.useState par un useState custom (ça ne sert à rien)

Mon objectif était d’expliquer simplement le fonctionnement de ces fonctions qui sont useState et useReducer pour te permettre (et me permettre aussi au passage 😉) d’avoir une compréhension plus fine de celles-ci.