Extraire la logique d'état dans un réducteur
Les composants avec beaucoup de mises à jour d’état dispersées dans de nombreux gestionnaires d’événements peuvent devenir difficiles à maîtriser. Dans ces circonstances, vous pouvez consolider toute la logique de mise à jour d’état dans une seule fonction (idéalement extérieure au composant), appelée réducteur.
Vous allez apprendre
- Ce qu’est un réducteur
- Comment remplacer
useState
paruseReducer
- Quand utiliser un réducteur
- Comment l’écrire correctement
Consolider la logique d’état avec un réducteur
Plus vos composants deviennent complexes, plus il est difficile de voir d’un coup d’œil les différentes façons dont leurs états sont mis à jour. Par exemple, le composant TaskApp
ci-dessous contient un tableau de tasks
dans un état et utilise trois gestionnaires d’événements différents pour créer, supprimer ou éditer ces tâches :
import { useState } from 'react'; import AddTask from './AddTask.js'; import TaskList from './TaskList.js'; export default function TaskApp() { const [tasks, setTasks] = useState(initialTasks); function handleAddTask(text) { setTasks([ ...tasks, { id: nextId++, text: text, done: false, }, ]); } function handleChangeTask(task) { setTasks( tasks.map((t) => { if (t.id === task.id) { return task; } else { return t; } }) ); } function handleDeleteTask(taskId) { setTasks(tasks.filter((t) => t.id !== taskId)); } return ( <> <h1>Voyage à Prague</h1> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} onChangeTask={handleChangeTask} onDeleteTask={handleDeleteTask} /> </> ); } let nextId = 3; const initialTasks = [ {id: 0, text: 'Visiter le musée Franz-Kafka', done: true}, {id: 1, text: 'Voir un spectacle de marionnettes', done: false}, {id: 2, text: 'Prendre une photo du mur John Lennon', done: false}, ];
Chaque gestionnaire d’événement appelle setTasks
afin de mettre à jour l’état. Avec l’évolution de ce composant, la quantité de logique qu’il contient grandit également. Pour réduire cette complexité et garder votre logique en un seul endroit facile d’accès, vous pouvez la déplacer dans une fonction unique à l’extérieur du composant, appelée « réducteur ».
Les réducteurs proposent une autre façon de gérer l’état. Vous pouvez migrer de useState
à useReducer
en trois étapes :
- Passez de l’écriture de l’état au dispatch d’actions.
- Écrivez la fonction du réducteur.
- Utilisez le réducteur depuis votre composant.
Étape 1 : passez de l’écriture de l’état au dispatch d’actions
Vos gestionnaires d’événements spécifient pour le moment ce qu’il faut faire en remplaçant l’état :
function handleAddTask(text) {
setTasks([
...tasks,
{
id: nextId++,
text: text,
done: false,
},
]);
}
function handleChangeTask(task) {
setTasks(
tasks.map((t) => {
if (t.id === task.id) {
return task;
} else {
return t;
}
})
);
}
function handleDeleteTask(taskId) {
setTasks(tasks.filter((t) => t.id !== taskId));
}
Supprimez toute la logique de définition d’état. Il vous reste ces trois gestionnaires d’événements :
handleAddTask(text)
est appelé quand l’utilisateur appuie sur « Ajouter ».handleChangeTask(task)
est appelé quand l’utilisateur bascule l’état de complétion d’une tâche ou appuie sur « Enregistrer ».handleDeleteTask(taskId)
est appelé quand l’utilisateur appuie sur « Supprimer ».
La gestion de l’état avec des réducteurs diffère légèrement d’une définition directe de l’état. Plutôt que de dire à React « quoi faire » en définissant l’état, vous dites « ce que l’utilisateur vient de faire » en émettant des « actions » à partir de vos gestionnaires d’événements (la logique de mise à jour de l’état se situe ailleurs). Ainsi, au lieu de « définir tasks
» via un gestionnaire d’événement, vous dispatchez une action « ajout / mise à jour / suppression d’une tâche ». C’est davantage une description de l’intention de l’utilisateur.
function handleAddTask(text) {
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}
function handleChangeTask(task) {
dispatch({
type: 'changed',
task: task,
});
}
function handleDeleteTask(taskId) {
dispatch({
type: 'deleted',
id: taskId,
});
}
L’objet que vous passez à dispatch
est appelé une « action » :
function handleDeleteTask(taskId) {
dispatch(
// objet « action » :
{
type: 'deleted',
id: taskId,
}
);
}
C’est un objet JavaScript ordinaire. Vous décidez ce que vous y mettez, mais généralement il ne doit contenir que les informations sur ce qui vient d’arriver (vous ajouterez la fonction dispatch
vous-même dans une prochaine étape).
Étape 2 : écrivez une fonction de réduction
Votre logique d’état se situera dans une fonction de réduction. Elle prend deux arguments, l’état courant et l’objet d’action, puis renvoie le nouvel état :
function yourReducer(state, action) {
// renvoie le prochain état pour que React l'utilise
}
React définira l’état avec ce qu’aura renvoyé le réducteur.
Pour déplacer votre logique de définition d’état des gestionnaires d’événements à une fonction de réduction dans cet exemple, vous :
- Déclarerez l’état courant (
tasks
) comme premier argument. - Déclarerez l’objet
action
comme second argument. - Renverrez le prochain état depuis le réducteur (à partir duquel React fixera l’état).
Voici toute la logique de définition d’état une fois migrée vers une fonction de réduction :
function tasksReducer(tasks, action) {
if (action.type === 'added') {
return [
...tasks,
{
id: action.id,
text: action.text,
done: false,
},
];
} else if (action.type === 'changed') {
return tasks.map((t) => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
} else if (action.type === 'deleted') {
return tasks.filter((t) => t.id !== action.id);
} else {
throw Error('Action inconnue : ' + action.type);
}
}
Puisque la fonction de réduction prend l’état (tasks
) comme argument, vous pouvez la déclarer hors de votre composant. Ça réduit le niveau d’indentation et rend votre code plus facile à lire.
En détail
Bien que les réducteurs puissent « réduire » la taille du code dans votre composant, ils sont en réalité appelés ainsi en référence à l’opération reduce()
que vous pouvez exécuter sur les tableaux.
L’opération reduce()
permet de prendre un tableau puis « d’accumuler » une seule valeur à partir de celles du tableau :
const arr = [1, 2, 3, 4, 5];
const sum = arr.reduce(
(result, number) => result + number
); // 1 + 2 + 3 + 4 + 5
La fonction que vous passez à reduce
est appelée « réducteur ». Elle prend le résultat en cours et l’élément courant, puis renvoie le prochain résultat. Les réducteurs React sont un exemple de la même idée : ils prennent l’état en cours et une action, puis renvoient le prochain état. De cette façon, ils accumulent avec le temps les actions au sein de l’état.
Vous pourriez d’ailleurs utiliser la méthode reduce()
avec un initialState
et un tableau d’actions
pour calculer l’état final en lui passant votre fonction de réduction :
import tasksReducer from './tasksReducer.js'; let initialState = []; let actions = [ {type: 'added', id: 1, text: 'Visiter le musée Franz-Kafka'}, {type: 'added', id: 2, text: 'Voir un spectacle de marionnettes'}, {type: 'deleted', id: 1}, {type: 'added', id: 3, text: 'Prendre une photo du mur John Lennon'}, ]; let finalState = actions.reduce(tasksReducer, initialState); const output = document.getElementById('output'); output.textContent = JSON.stringify(finalState, null, 2);
Vous n’aurez probablement pas besoin de le faire vous-même, mais c’est similaire à ce que fait React !
Étape 3 : utilisez le réducteur depuis votre composant
Pour finir, vous devez connecter le tasksReducer
à votre composant. Commencez par importer le Hook useReducer
depuis React :
import { useReducer } from 'react';
Ensuite, vous pouvez remplacer le useState
:
const [tasks, setTasks] = useState(initialTasks);
…par useReducer
de cette façon :
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
Le Hook useReducer
est similaire à useState
: d’une part vous devez lui passer un état initial, d’autre part il renvoie une valeur d’état ainsi qu’un moyen de le redéfinir (en l’occurrence, la fonction de dispatch). Toutefois, des différences existent.
Le Hook useReducer
prend deux arguments :
- Une fonction de réduction.
- Un état initial.
Il renvoie :
- Une valeur d’état.
- Une fonction dispatch (pour « dispatcher » les actions de l’utilisateur vers le réducteur).
Tout est câblé maintenant ! Ici, le réducteur est déclaré à la fin du fichier de composant :
import { useReducer } from 'react'; import AddTask from './AddTask.js'; import TaskList from './TaskList.js'; export default function TaskApp() { const [tasks, dispatch] = useReducer(tasksReducer, initialTasks); function handleAddTask(text) { dispatch({ type: 'added', id: nextId++, text: text, }); } function handleChangeTask(task) { dispatch({ type: 'changed', task: task, }); } function handleDeleteTask(taskId) { dispatch({ type: 'deleted', id: taskId, }); } return ( <> <h1>Voyage à Prague</h1> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} onChangeTask={handleChangeTask} onDeleteTask={handleDeleteTask} /> </> ); } function tasksReducer(tasks, action) { switch (action.type) { case 'added': { return [ ...tasks, { id: action.id, text: action.text, done: false, }, ]; } case 'changed': { return tasks.map((t) => { if (t.id === action.task.id) { return action.task; } else { return t; } }); } case 'deleted': { return tasks.filter((t) => t.id !== action.id); } default: { throw Error('Action inconnue : ' + action.type); } } } let nextId = 3; const initialTasks = [ {id: 0, text: 'Visiter le musée Franz-Kafka', done: true}, {id: 1, text: 'Voir un spectacle de marionnettes', done: false}, {id: 2, text: 'Prendre une photo du mur John Lennon', done: false}, ];
Si vous voulez, vous pouvez même déplacer le réducteur dans un fichier à part :
import { useReducer } from 'react'; import AddTask from './AddTask.js'; import TaskList from './TaskList.js'; import tasksReducer from './tasksReducer.js'; export default function TaskApp() { const [tasks, dispatch] = useReducer(tasksReducer, initialTasks); function handleAddTask(text) { dispatch({ type: 'added', id: nextId++, text: text, }); } function handleChangeTask(task) { dispatch({ type: 'changed', task: task, }); } function handleDeleteTask(taskId) { dispatch({ type: 'deleted', id: taskId, }); } return ( <> <h1>Voyage à Prague</h1> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} onChangeTask={handleChangeTask} onDeleteTask={handleDeleteTask} /> </> ); } let nextId = 3; const initialTasks = [ {id: 0, text: 'Visiter le musée Franz-Kafka', done: true}, {id: 1, text: 'Voir un spectacle de marionnettes', done: false}, {id: 2, text: 'Prendre une photo du mur John Lennon', done: false}, ];
La logique des composants peut être plus simple à lire quand vous séparez les responsabilités de cette façon. Maintenant les gestionnaires d’événements spécifient seulement ce qui s’est passé en dispatchant les actions, et la fonction de réduction détermine comment l’état se met à jour en réponse à celles-ci.
Comparaison de useState
et useReducer
Les réducteurs ne sont pas sans inconvénients ! Voici quelques éléments de comparaison :
- Taille du code : avec un
useState
, vous devez généralement écrire moins de code au début. AvecuseReducer
, vous devez écrire à la fois la fonction de réduction et dispatcher les actions. Cependant,useReducer
peut aider à réduire le code si plusieurs gestionnaires d’événements modifient l’état local de façon similaire. - Lisibilité :
useState
est très facile à lire lorsque les mises à jour d’état sont simples. Mais quand ça se complique, elles peuvent gonfler le code de votre composant et le rendre difficile à analyser. Dans ce cas,useReducer
vous permet de séparer proprement le comment de la logique du ce qui est arrivé des gestionnaires d’événements. - Débogage : quand vous avez un bug avec un
useState
, il peut être difficile de dire où l’état a été mal défini et pourquoi. Avec unuseReducer
, vous pouvez ajouter des messages dans la console depuis votre réducteur pour voir chaque mise à jour d’état et pourquoi elles ont lieu (en rapport à quelleaction
). Si chaqueaction
est correcte, vous saurez que le problème se trouve dans la logique de réduction elle-même. En revanche, vous devez parcourir plus de code qu’avecuseState
. - Tests : un réducteur est une fonction pure qui ne dépend pas de votre composant. Ça signifie que vous pouvez l’exporter et la tester en isolation. Bien qu’il soit généralement préférable de tester des composants dans un environnement plus réaliste, pour une logique de mise à jour d’état plus complexe, il peut être utile de vérifier que votre réducteur renvoie un état spécifique pour un état initial et une action particuliers.
- Préférence personnelle : certaines personnes aiment les réducteurs, d’autres non. Ce n’est pas grave. C’est une question de préférence. Vous pouvez toujours convertir un
useState
en unuseReducer
et inversement : ils sont équivalents !
Nous recommandons d’utiliser un réducteur si vous rencontrez souvent des bugs à cause de mauvaises mises à jour d’état dans un composant et que vous souhaitez introduire plus de structure dans son code. Vous n’êtes pas obligé·e d’utiliser les réducteurs pour tout : n’hésitez pas à mélanger les approches ! Vous pouvez aussi utiliser useState
et useReducer
dans le même composant.
Écrire les réducteurs correctement
Gardez ces deux points à l’esprit quand vous écrivez des réducteurs :
- Les réducteurs doivent être purs. Tout comme les fonctions de mise à jour d’état, les réducteurs sont exécutés pendant le rendu! ! (Les actions sont mises en attente jusqu’au rendu suivant.) Ça signifie que les réducteurs doivent être purs — les mêmes entrées doivent toujours produire les mêmes sorties. Ils ne doivent pas envoyer de requêtes, planifier des timers ou traiter des effets secondaires (des opérations qui impactent des entités extérieures au composant). Ils doivent mettre à jour des objets et des tableaux en respectant l’immutabilité.
- Chaque action décrit une interaction utilisateur unique, même si ça entraîne plusieurs modifications des données. Par exemple, si l’utilisateur appuie sur le bouton « Réinitialiser » d’un formulaire comportant cinq champs gérés par un réducteur, il sera plus logique de dispatcher une seule action
reset_form
plutôt que cinq actionsset_field
distinctes. Si vous journalisez chaque action d’un réducteur, ce journal doit être suffisamment clair pour vous permettre de reconstruire l’ordre et la nature des interactions et de leurs traitements. Ça facilite le débogage !
Écrire des réducteurs concis avec Immer
Comme pour la mise à jour des objets et des tableaux dans un état ordinaire, vous pouvez utiliser la bibliothèque Immer pour rendre les réducteurs plus concis. Ici, useImmerReducer
vous permet de modifier l’état avec un appel à push
ou encore une affectation arr[i] =
:
import { useImmerReducer } from 'use-immer'; import AddTask from './AddTask.js'; import TaskList from './TaskList.js'; function tasksReducer(draft, action) { switch (action.type) { case 'added': { draft.push({ id: action.id, text: action.text, done: false, }); break; } case 'changed': { const index = draft.findIndex((t) => t.id === action.task.id); draft[index] = action.task; break; } case 'deleted': { return draft.filter((t) => t.id !== action.id); } default: { throw Error('Action inconnue : ' + action.type); } } } export default function TaskApp() { const [tasks, dispatch] = useImmerReducer(tasksReducer, initialTasks); function handleAddTask(text) { dispatch({ type: 'added', id: nextId++, text: text, }); } function handleChangeTask(task) { dispatch({ type: 'changed', task: task, }); } function handleDeleteTask(taskId) { dispatch({ type: 'deleted', id: taskId, }); } return ( <> <h1>Voyage à Prague</h1> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} onChangeTask={handleChangeTask} onDeleteTask={handleDeleteTask} /> </> ); } let nextId = 3; const initialTasks = [ {id: 0, text: 'Visiter le musée Franz-Kafka', done: true}, {id: 1, text: 'Voir un spectacle de marionnettes', done: false}, {id: 2, text: 'Prendre une photo du mur John Lennon', done: false}, ];
Les réducteurs doivent être purs, donc ils ne doivent pas modifier l’état. Cependant, Immer fournit un objet spécial draft
qu’il est possible de modifier. Sous le capot, Immer créera une copie de votre état avec les changements que vous avez appliqué sur le draft
. C’est pourquoi les réducteurs gérés par useImmerReducer
peuvent modifier leur premier argument et n’ont pas besoin de renvoyer l’état.
En résumé
- Pour convertir
useState
versuseReducer
:- Dispatchez les actions depuis des gestionnaires d’événements.
- Écrivez une fonction de réduction qui s’occupera de renvoyer le prochain état, à partir d’un état et d’une action donnés.
- Remplacez
useState
paruseReducer
.
- Les réducteurs vous obligent à écrire un peu plus de code, mais ils facilitent le débogage et les tests.
- Les réducteurs doivent être purs.
- Chaque action décrit une interaction utilisateur unique.
- Utilisez Immer si vous souhaitez écrire des réducteurs en modifiant directement l’état entrant.
Défi 1 sur 4 · Dispatcher des actions depuis des gestionnaires d’événements
Pour l’instant, les gestionnaires d’événements dans ContactList.js
et Chat.js
contiennent des commentaires // TODO
. C’est pour ça que taper au clavier dans le champ de saisie ne marche pas, et cliquer sur les boutons ne change pas le destinataire sélectionné.
Remplacez ces deux commentaires // TODO
par du code qui dispatch
les actions correspondantes. Pour connaître la forme attendue et le type des actions, allez voir le réducteur dans messengerReducer.js
. Il est déjà écrit, vous n’avez donc pas besoin de le changer. Vous devez seulement dispatcher les actions dans ContactList.js
et Chat.js
.
import { useReducer } from 'react'; import Chat from './Chat.js'; import ContactList from './ContactList.js'; import { initialState, messengerReducer } from './messengerReducer'; export default function Messenger() { const [state, dispatch] = useReducer(messengerReducer, initialState); const message = state.message; const contact = contacts.find((c) => c.id === state.selectedId); return ( <div> <ContactList contacts={contacts} selectedId={state.selectedId} dispatch={dispatch} /> <Chat key={contact.id} message={message} contact={contact} dispatch={dispatch} /> </div> ); } const contacts = [ {id: 0, name: 'Clara', email: 'clara@mail.com'}, {id: 1, name: 'Alice', email: 'alice@mail.com'}, {id: 2, name: 'Bob', email: 'bob@mail.com'}, ];