Escalar con reducer y contexto
Los reducers permiten consolidar la lógica de actualización del estado de un componente. La API de contexto (Context) te permite pasar información en profundidad a otros componentes. Puedes combinar los reducers y el contexto para gestionar el estado de una pantalla compleja.
Aprenderás
- Cómo combinar un reducer con el contexto
- Cómo evitar pasar el estado y la función dispatch a través de props
- Cómo mantener la lógica del contexto y del estado en un archivo separado
Combinar un reducer con el contexto
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 manejadores de eventos cortos y concisos. Sin embargo, a medida que tu aplicación crece, puedes encontrarte con otra dificultad. Actualmente, el estado tasks
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, tienes que pasar explícitamente el estado actual y los manejadores de eventos que lo cambian como props.
Por ejemplo, TaskApp
pasa una lista de tareas y los manejadores de eventos a TaskList
:
<TaskList
tasks={tasks}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
/>
Y TaskList
pasa los manejadores de eventos a Task
:
<Task
task={task}
onChange={onChangeTask}
onDelete={onDeleteTask}
/>
En un ejemplo pequeño como éste, 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 el contexto. De esta manera, cualquier componente por debajo de TaskApp
en el árbol puede leer las tareas y enviar acciones sin la “perforación de props” (o “prop drilling”).
A continuación se explica cómo se puede combinar un reducer con el contexto:
- Crea el contexto.
- Pon el estado y la función dispatch en el contexto.
- Usa el contexto en cualquier parte del árbol.
Paso 1: Crea el contexto
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, crearás dos contextos distintos:
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 contextos. Los valores reales serán proporcionados por el componente TaskApp
.
Paso 2: Poner en contexto el estado y dispatch
Ahora puedes importar ambos contextos en tu componente TaskApp
. Toma tasks
y dispatch
que devuelve useReducer()
y proporciónalos 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 contexto:
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 de props.
Paso 3: Utiliza el contexto en cualquier parte del árbol
Ahora no es necesario pasar la lista de tareas o los manejadores 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 contexto 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 manejador de eventos hacia abajo, y TaskList
tampoco pasa ningún manejador de eventos al componente Task
. Cada componente lee el contexto 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 (tasks
) y dispatch
están ahora disponibles para todos los componentes por debajo en el árbol mediante la importación y el uso de estos contextos.
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 contexto a un solo archivo. Actualmente, TasksContext.js
contiene solo 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:
- Gestionará el estado con un reducer.
- Proporcionará ambos contextos a los componentes de abajo.
- Tomará
children
como prop para que puedas pasarle 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 contexto desde TasksContext.js
:
export function useTasks() {
return useContext(TasksContext);
}
export function useTasksDispatch() {
return useContext(TasksDispatchContext);
}
Cuando un componente necesita leer el contexto, 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 contextos 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
yuseTasksDispatch
se llaman Hooks personalizados (Custom Hooks). Tu función se considera un Hook personalizado si su nombre empieza poruse
. Esto te permite usar otros Hooks, comouseContext
, dentro de ella.
A medida que tu aplicación crece, puedes tener muchos pares contexto-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 contexto para permitir que cualquier componente lea y actualice el estado por encima de él.
- Para proporcionar estado y la función dispatch a los componentes de abajo:
- Crea dos contextos (para el estado y para las funciones dispatch).
- Proporciona ambos contextos desde el componente que utiliza el reducer.
- Utiliza cualquiera de los dos contextos 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 contexto. - También puedes exportar Hooks personalizados como
useTasks
yuseTasksDispatch
para leerlo.
- Puedes exportar un componente como
- Puedes tener muchos pares context-reducer como este en tu aplicación.