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:
- Crear el context.
- Poner el estado y el envío en el context.
- 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:
- Gestionará el estado con un reducer.
- Proporcionará ambos context a los componentes de abajo.
- Tomará como prop a los
hijos
. - 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
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 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:
- Cree dos context (para el state y para las funciones de dispatch).
- Proporcione ambos context desde el componente que utiliza el reducer.
- 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
yuseTasksDispatch
para leerlo.
- Puedes exportar un componente como
- Puedes tener muchos pares context-reducer como este en tu aplicación.