Escalando con Reducer y Context

Los Reducers permiten consolidar la lógica de actualización del estado de un componente. El context te permite pasar información en profundidad a otros componentes. Puedes combinar los reducers y el context para gestionar el estado de una pantalla compleja.

Aprenderás

  • Cómo combinar un reducer con el context
  • Cómo evitar pasar el state y el dispatch a través de props
  • Cómo mantener la lógica del context y del estado en un archivo separado

Combininandi un reducer con un context

En este ejemplo de Introducción a reducers, el estado es gestionado por un reducer. La función reducer contiene toda la lógica de actualización del estado y se declara al final de este archivo:

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>Day off in 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: 'Philosopher’s Path', done: true },
  { id: 1, text: 'Visit the temple', done: false },
  { id: 2, text: 'Drink matcha', done: false }
];

Un reducer ayuda a mantener los handlers de eventos cortos y concisos. Sin embargo, a medida que tu aplicación crece, puedes encontrarte con otra dificultad. Actualmente, el estado de las tareas y la función dispatch sólo están disponibles en el componente de nivel superior TaskApp. Para permitir que otros componentes lean la lista de tareas o la modifiquen, tiene que explicitar pass down el estado actual y los handlers de eventos que lo cambian como props.

Por ejemplo, TaskApp pasa una lista de tareas y los handlers de eventos a TaskList:

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

And TaskList passes the event handlers to Task:

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

En un ejemplo pequeño como éste, esto funciona bien, pero si tienes decenas o cientos de componentes en el medio, ¡pasar todo el estado y las funciones puede ser bastante frustrante!

Por eso, como alternativa a pasarlas por props, podrías poner tanto el estado tasks como la función dispatch en context. De esta manera, cualquier componente por debajo de TaskApp en el árbol puede leer las tareas y enviar acciones sin la repetitiva “prop drilling”.

A continuación se explica cómo se puede combinar un reducer con el contexto:

  1. Crear el context.
  2. Poner el estado y el envío en el context.
  3. Utilizar el context en cualquier parte del árbol.

Paso 1: Creaar el context

El hook useReducer devuelve las tareas actuales y la función dispatch que permite actualizarlas:

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

Para pasarlos por el árbol, tienes create dos context distintos:

  • El TasksContext proporciona la lista actual de tareas.
  • TasksDispatchContext proporciona la función que permite a los componentes enviar acciones.

Expórtalos desde un archivo separado para poder importarlos posteriormente desde otros archivos:

import { createContext } from 'react';

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

Aquí, estás pasando null como valor por defecto a ambos context. Los valores reales serán proporcionados por el componente TaskApp.

Paso 2: Poner en contexto el state y el dispatch

Ahora puedes importar ambos context en tu componente TaskApp. Toma las tareas y dispatch devueltas por useReducer() y proporciónalas a todo el árbol de abajo::

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

Por ahora, se pasa la información tanto vía props como en context:

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>Day off in 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: 'Philosopher’s Path', done: true },
  { id: 1, text: 'Visit the temple', done: false },
  { id: 2, text: 'Drink matcha', done: false }
];

En el siguiente paso, se eliminará el paso del prop.

Paso 3: Utilizar el context en cualquier parte del árbol

Ahora no es necesario pasar la lista de tareas o los handlers de eventos por el árbol:

<TasksContext.Provider value={tasks}>
<TasksDispatchContext.Provider value={dispatch}>
<h1>Day off in Kyoto</h1>
<AddTask />
<TaskList />
</TasksDispatchContext.Provider>
</TasksContext.Provider>

En cambio, cualquier componente que necesite la lista de tareas puede leerla del TaskContext:

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

Para actualizar la lista de tareas, cualquier componente puede leer la función dispatch del context y llamarla:

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

El componente TaskApp no pasa ningún handler de eventos hacia abajo, y el TaskList tampoco pasa ningún handler de eventos al componente Task. Cada componente lee el context que necesita:

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)}>
          Save
        </button>
      </>
    );
  } else {
    taskContent = (
      <>
        {task.text}
        <button onClick={() => setIsEditing(true)}>
          Edit
        </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
        });
      }}>
        Delete
      </button>
    </label>
  );
}

El estado todavía “vive” en el componente de nivel superior TaskApp, gestionado con useReducer. Pero sus tareas y dispatch están ahora disponibles para todos los componentes por debajo en el árbol mediante la importación y el uso de estos context.

Trasladar todo la lógica a un único archivo

No es necesario que lo hagas, pero podrías simplificar aún más los componentes moviendo tanto el reducer como el context a un solo archivo. Actualmente, TasksContext.js contiene sólo dos declaraciones de contexto:

import { createContext } from 'react';

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

¡Este archivo está a punto de complicarse! Moverás el reducer a ese mismo archivo. A continuación, declararás un nuevo componente TasksProvider en el mismo archivo. Este componente unirá todas las piezas:

  1. Gestionará el estado con un reducer.
  2. Proporcionará ambos context a los componentes de abajo.
  3. Tomará como prop a los hijos.
  4. Tomará como prop a los hijos para que puedas pasarlo a 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>
);
}

Esto elimina toda la complejidad y la lógica del componente TaskApp:

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

export default function TaskApp() {
  return (
    <TasksProvider>
      <h1>Day off in Kyoto</h1>
      <AddTask />
      <TaskList />
    </TasksProvider>
  );
}

También puedes exportar funciones que utilicen el context desde TasksContext.js:

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

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

Cuando un componente necesita leer el context, puede hacerlo a través de estas funciones:

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

Esto no cambia el comportamiento de ninguna manera, pero te permite dividir más tarde estos context o añadir algo de lógica a estas funciones. Ahora todo la lógica del contexto y del reducer está en TasksContext.js. Esto mantiene los componentes limpios y despejados, centrados en lo que muestran en lugar de donde obtienen los datos:

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)}>
          Save
        </button>
      </>
    );
  } else {
    taskContent = (
      <>
        {task.text}
        <button onClick={() => setIsEditing(true)}>
          Edit
        </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
        });
      }}>
        Delete
      </button>
    </label>
  );
}

Puedes pensar en TasksProvider como una parte de la pantalla que sabe cómo tratar con las tareas, useTasks como una forma de leerlas, y useTasksDispatch como una forma de actualizarlas desde cualquier componente de abajo en el árbol.

Funciones como useTasks y useTasksDispatch se llaman Hooks personalizados (Custom Hooks). Tu función se considera un Hook personalizado si su nombre empieza por use. Esto te permite usar otros Hooks, como useContext, dentro de ella.

A medida que tu aplicación crece, puedes tener muchos pares context-reducer como este. Esta es una poderosa forma de escalar tu aplicación y manejar el estado sin demasiado trabajo cada vez que se quiera acceder a los datos en la profundidad del árbol.

Recapitulación

  • Puedes combinar el reducer con el context para permitir que cualquier componente lea y actualice el estado por encima de él.
  • Para proporcionar el estado y la función de envío a los componentes de abajo:
    1. Cree dos context (para el state y para las funciones de dispatch).
    2. Proporcione ambos context desde el componente que utiliza el reducer.
    3. Utiliza cualquiera de los dos context desde los componentes que necesiten leerlos.
  • Puedes refactorizar aún más los componentes moviendo todo la lógica a un solo archivo.
    • Puedes exportar un componente como TasksProvider que proporciona el context.
    • También puedes exportar hooks personalizados como useTasks y useTasksDispatch para leerlo.
  • Puedes tener muchos pares context-reducer como este en tu aplicación.