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
etsetBanane
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 fonctiondispatch
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.