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 par useReducer
  • 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 :

  1. Passez de l’écriture de l’état au dispatch d’actions.
  2. Écrivez la fonction du réducteur.
  3. 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).

Remarque

Un objet action peut avoir n’importe quelle forme.

Par convention, il est courant d’y mettre une propriété textuelle type qui décrit ce qui s’est passé, et d’ajouter les informations complémentaires dans d’autres champs. Le type est spécifique à un composant, donc 'added' ou 'added_task' conviendraient pour cet exemple. Choisissez un nom qui décrit ce qui s’est passé !

dispatch({
// Spécifique au composant
type: 'what_happened',
// Les autres champs vont ici
});

É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 :

  1. Déclarerez l’état courant (tasks) comme premier argument.
  2. Déclarerez l’objet action comme second argument.
  3. 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.

Remarque

Le code plus haut utilise des instructions if / else, mais nous utiliserons l’instruction switch au sein des réducteurs. Le résultat est le même, mais il est sans doute plus facile de lire des instructions switch d’un coup d’œil.

Nous les utiliserons dans le reste de cette documentation de cette façon :

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);
}
}
}

Nous recommandons d’enrober chaque bloc case entre des accolades { et } afin qu’il n’y ait pas d’interférences entre les variables déclarées dans chacun des case. De plus, un case doit généralement se terminer par un return. Si vous l’oubliez, le code va « dégringoler » sur le case suivant, ce qui peut entraîner des erreurs !

Si vous n’êtes pas à l’aise avec les instructions switch, vous pouvez tout à fait utiliser des if / else.

En détail

D’où vient le terme « réducteur » ?

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 :

  1. Une fonction de réduction.
  2. Un état initial.

Il renvoie :

  1. Une valeur d’état.
  2. 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. Avec useReducer, 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 l’état a été mal défini et pourquoi. Avec un useReducer, 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 à quelle action). Si chaque action 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’avec useState.
  • 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 un useReducer 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 actions set_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] = :

{
  "dependencies": {
    "immer": "1.7.3",
    "react": "latest",
    "react-dom": "latest",
    "react-scripts": "latest",
    "use-immer": "0.5.1"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  },
  "devDependencies": {}
}

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 vers useReducer :
    1. Dispatchez les actions depuis des gestionnaires d’événements.
    2. Écrivez une fonction de réduction qui s’occupera de renvoyer le prochain état, à partir d’un état et d’une action donnés.
    3. Remplacez useState par useReducer.
  • 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'},
];