Mise à l'échelle en combinant réducteur et contexte

Les réducteurs vous permettent de consolider la logique de mise à jour de l’état d’un composant. Le contexte vous offre la possibilité de transmettre de l’information en profondeur à d’autres composants. Vous pouvez les combiner afin de gérer l’état d’un écran complexe.

Vous allez apprendre

  • Comment combiner un réducteur avec un contexte
  • Comment éviter de passer l’état et le dispatcher au travers des props
  • Comment conserver le contexte et la logique d’état dans un fichier séparé

Combiner un réducteur avec un contexte

Dans cet exemple provenant de l’introduction aux réducteurs, l’état est géré par un réducteur. Cette fonction (déclarée à la fin du fichier) contient toute la logique de mise à jour de l’état :

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>Jour de repos à Kyoto</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('Unknown action: ' + action.type);
    }
  }
}

let nextId = 3;
const initialTasks = [
  { id: 0, text: 'La promenade du philosophe', done: true },
  { id: 1, text: 'Visiter le temple', done: false },
  { id: 2, text: 'Boire du thé matcha', done: false }
];

Un réducteur permet de garder les gestionnaires d’événements concis. Vous pouvez cependant rencontrer une autre difficulté à mesure que votre appli grandit. Pour le moment, l’état tasks et le fonction dispatch ne sont disponibles qu’au niveau du composant racine TaskApp. Pour que les autres composants puissent lire ou modifier la liste de tâches, vous devez explicitement transmettre via les props l’état courant et les gestionnaires d’événements qui le modifient.

Par exemple, TaskApp passe une liste de tâches et les gestionnaires d’événements à TaskList :

<TaskList
tasks={tasks}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
/>

Et TaskList passe les gestionnaires d’événements à Task :

<Task
task={task}
onChange={onChangeTask}
onDelete={onDeleteTask}
/>

Ça fonctionne bien pour un petit exemple comme celui-là, mais si vous avez des dizaines voire des centaines de composants au milieu, transmettre les états et gestionnaires d’événements peut s’avérer plutôt frustrant !

C’est pourquoi, plutôt que de les passer via les props, vous pourriez mettre l’état tasks et la fonction dispatch dans un contexte. De cette façon, tous les composants en dessous de TaskApp dans l’arbre peuvent lire les tâches et dispatcher les actions sans la « percolation des props ».

Voici comment vous pouvez combiner un réducteur avec un contexte :

  1. Créez le contexte.
  2. Mettez l’état et la fonction de dispatch dans le contexte.
  3. Utilisez le contexte n’importe où dans l’arbre.

Étape 1 : créer le contexte

Le Hook useReducer renvoie les tasks courantes et la fonction dispatch qui vous permet de les mettre à jour :

const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

Pour les passer à travers l’arbre, vous aller créer deux contextes distincts :

  • TasksContext fournit la liste actuelle des tâches.
  • TasksDispatchContext fournit la fonction qui permet aux composants de dispatcher les actions.

Exportez-les à partir d’un fichier dédié de façon à pouvoir les importer ultérieurement dans d’autres fichiers :

import { createContext } from 'react';

export const TasksContext = createContext(null);
export const TasksDispatchContext = createContext(null);

Ici, vous passez null comme valeur par défaut aux deux contextes. Les valeurs réelles seront fournies par le composant TaskApp.

Étape 2 : placer l’état et le dispatch dans le contexte

Vous pouvez maintenant importer les deux contextes dans votre composant TaskApp. Prenez les tasks et dispatch renvoyées par le useReducer() et fournissez-les à l’arbre entier en dessous :

import { TasksContext, TasksDispatchContext } from './TasksContext.js';

export default function TaskApp() {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
// ...
return (
<TasksContext.Provider value={tasks}>
<TasksDispatchContext.Provider value={dispatch}>
...
</TasksDispatchContext.Provider>
</TasksContext.Provider>
);
}

Pour l’instant, vous passez l’information à la fois par les props et dans le contexte :

import { useReducer } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
import { TasksContext, TasksDispatchContext } from './TasksContext.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 (
    <TasksContext.Provider value={tasks}>
      <TasksDispatchContext.Provider value={dispatch}>
        <h1>Jour de repos à Kyoto</h1>
        <AddTask
          onAddTask={handleAddTask}
        />
        <TaskList
          tasks={tasks}
          onChangeTask={handleChangeTask}
          onDeleteTask={handleDeleteTask}
        />
      </TasksDispatchContext.Provider>
    </TasksContext.Provider>
  );
}

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('Unknown action: ' + action.type);
    }
  }
}

let nextId = 3;
const initialTasks = [
  { id: 0, text: 'La promenade du philosophe', done: true },
  { id: 1, text: 'Visiter le temple', done: false },
  { id: 2, text: 'Boire du thé matcha', done: false }
];

Vous cesserez d’utiliser les props dans la prochaine étape.

Étape 3 : utiliser un contexte n’importe où dans l’arbre

Vous n’avez désormais plus besoin de passer la liste de tâches ou les gestionnaires d’événements à travers l’arbre :

<TasksContext.Provider value={tasks}>
<TasksDispatchContext.Provider value={dispatch}>
<h1>Jour de repos à Kyoto</h1>
<AddTask />
<TaskList />
</TasksDispatchContext.Provider>
</TasksContext.Provider>

Au lieu de ça, un composant qui a besoin de la liste de tâches peut la lire depuis le TaskContext :

export default function TaskList() {
const tasks = useContext(TasksContext);
// ...

Pour mettre à jour la liste de tâches, un composant peut lire la fonction dispatch depuis le contexte et l’appeler :

export default function AddTask() {
const [text, setText] = useState('');
const dispatch = useContext(TasksDispatchContext);
// ...
return (
// ...
<button onClick={() => {
setText('');
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}}>Ajouter</button>
// ...

Le composant TaskApp ne transmet aucun gestionnaire d’événement vers le bas et la TaskList ne transmet pas non plus de gestionnaire d’événement au composant Task. Chaque composant lit le contexte dont il a besoin :

import { useState, useContext } from 'react';
import { TasksContext, TasksDispatchContext } from './TasksContext.js';

export default function TaskList() {
  const tasks = useContext(TasksContext);
  return (
    <ul>
      {tasks.map(task => (
        <li key={task.id}>
          <Task task={task} />
        </li>
      ))}
    </ul>
  );
}

function Task({ task }) {
  const [isEditing, setIsEditing] = useState(false);
  const dispatch = useContext(TasksDispatchContext);
  let taskContent;
  if (isEditing) {
    taskContent = (
      <>
        <input
          value={task.text}
          onChange={e => {
            dispatch({
              type: 'changed',
              task: {
                ...task,
                text: e.target.value
              }
            });
          }} />
        <button onClick={() => setIsEditing(false)}>
          Enregistrer
        </button>
      </>
    );
  } else {
    taskContent = (
      <>
        {task.text}
        <button onClick={() => setIsEditing(true)}>
          Modifier
        </button>
      </>
    );
  }
  return (
    <label>
      <input
        type="checkbox"
        checked={task.done}
        onChange={e => {
          dispatch({
            type: 'changed',
            task: {
              ...task,
              done: e.target.checked
            }
          });
        }}
      />
      {taskContent}
      <button onClick={() => {
        dispatch({
          type: 'deleted',
          id: task.id
        });
      }}>
        Supprimer
      </button>
    </label>
  );
}

L’état « vit » toujours dans le composant de haut-niveau TaskApp, où il reste géré avec useReducer. Toutefois, ses tasks et dispatch sont désormais accessibles à chaque composant plus bas dans l’arbre, en important et utilisant ces contextes.

Déplacer toute cette plomberie dans un seul fichier

Vous n’êtes pas obligé·e de le faire, mais vous pouvez encore alléger les composants en déplaçant le réducteur et le contexte dans un seul fichier. Pour le moment, TasksContext.js ne contient que les deux déclarations de contexte :

import { createContext } from 'react';

export const TasksContext = createContext(null);
export const TasksDispatchContext = createContext(null);

Ce fichier va très vite grandir ! Vous allez déplacer le réducteur dans ce fichier. Ensuite, vous allez y déclarer un nouveau composant TasksProvider. Ce composant va relier toutes les pièces du puzzle :

  1. Il gérera l’état avec un réducteur.
  2. Il fournira les deux contextes aux composants en dessous.
  3. Il prendra les children en tant que props afin que vous puissiez lui donner du JSX.
export function TasksProvider({ children }) {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

return (
<TasksContext.Provider value={tasks}>
<TasksDispatchContext.Provider value={dispatch}>
{children}
</TasksDispatchContext.Provider>
</TasksContext.Provider>
);
}

Ça supprime toute la complexité et la plomberie de votre composant TaskApp :

import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
import { TasksProvider } from './TasksContext.js';

export default function TaskApp() {
  return (
    <TasksProvider>
      <h1>Jour de repos à Kyoto</h1>
      <AddTask />
      <TaskList />
    </TasksProvider>
  );
}

Vous pouvez également exporter des fonctions qui utilisent le contexte de TasksContext.js :

export function useTasks() {
return useContext(TasksContext);
}

export function useTasksDispatch() {
return useContext(TasksDispatchContext);
}

Quand un composant a besoin de lire le contexte, il peut le faire via ces fonctions :

const tasks = useTasks();
const dispatch = useTasksDispatch();

Ça ne change en rien le comportement, mais ça vous laisse l’opportunité de diviser davantage ces contextes ou d’ajouter de la logique à ces fonctions. Toute la plomberie du contexte et du réducteur se trouvent désormais dans TasksContext.js. Ça laisse les composants propres et épurés, et ils peuvent ainsi se concentrer sur ce qu’ils doivent afficher plutôt que savoir où chercher les données :

import { useState } from 'react';
import { useTasks, useTasksDispatch } from './TasksContext.js';

export default function TaskList() {
  const tasks = useTasks();
  return (
    <ul>
      {tasks.map(task => (
        <li key={task.id}>
          <Task task={task} />
        </li>
      ))}
    </ul>
  );
}

function Task({ task }) {
  const [isEditing, setIsEditing] = useState(false);
  const dispatch = useTasksDispatch();
  let taskContent;
  if (isEditing) {
    taskContent = (
      <>
        <input
          value={task.text}
          onChange={e => {
            dispatch({
              type: 'changed',
              task: {
                ...task,
                text: e.target.value
              }
            });
          }} />
        <button onClick={() => setIsEditing(false)}>
          Enregistrer
        </button>
      </>
    );
  } else {
    taskContent = (
      <>
        {task.text}
        <button onClick={() => setIsEditing(true)}>
          Modifier
        </button>
      </>
    );
  }
  return (
    <label>
      <input
        type="checkbox"
        checked={task.done}
        onChange={e => {
          dispatch({
            type: 'changed',
            task: {
              ...task,
              done: e.target.checked
            }
          });
        }}
      />
      {taskContent}
      <button onClick={() => {
        dispatch({
          type: 'deleted',
          id: task.id
        });
      }}>
        Supprimer
      </button>
    </label>
  );
}

Vous pouvez voir le TasksProvider comme une partie de l’écran qui sait comment traiter les tâches, useTasks comme une façon de les lire et useTasksDispatch comme un moyen de les mettre à jour à partir de n’importe quel composant plus bas dans l’arbre.

Remarque

Les fonctions comme useTasks et useTasksDispatch sont ce qu’on appelle des Hooks personnalisés. Votre fonction est considérée comme un Hook personnalisé si son nom commence par use. Vous pouvez alors utiliser d’autres Hooks comme useContext à l’intérieur.

Au fur et à mesure que votre appli grandit, il se peut que vous ayez de nombreuses paires contexte-réducteur comme celle-ci. C’est un moyen puissant de faire grandir votre appli et de faire remonter l’état sans trop d’efforts chaque fois que vous avez besoin d’accéder aux données au plus profond de l’arbre.

En résumé

  • Vous pouvez combiner le réducteur avec le contexte pour permettre à n’importe quel composant de lire et mettre à jour l’état qui se trouve au-dessus de lui.
  • Pour fournir l’état et la fonction de dispatch aux composants en dessous :
    1. Créez deux contextes (l’un pour l’état et l’autre pour les fonctions de dispatch).
    2. Fournissez ces deux contextes à partir du composant qui utilise le réducteur.
    3. Utilisez l’un ou l’autre des contextes pour les composants qui ont besoin de les lire.
  • Vous avez la possibilité d’alléger les composants en déplaçant toute la plomberie dans un seul fichier.
    • Vous pouvez exporter un composant tel que TasksProvider qui fournit le contexte.
    • Vous pouvez également exporter des Hooks personnalisés comme useTasks et useTasksDispatch pour les lire.
  • Vous pouvez disposer de nombreuses paires contexte-réducteur comme ça dans votre appli.