Extraer lógica de estado en un reducer
Los componentes con muchas actualizaciones de estado distribuidas a través de varios manejadores de eventos pueden ser agobiantes. Para estos casos, puedes consolidar toda la lógica de actualización de estado fuera del componente en una única función, llamada reducer.
Aprenderás
- Qué es una función de reducer
- Cómo refactorizar de
useState
auseReducer
- Cuándo utilizar un reducer
- Cómo escribir uno de manera correcta
Consolidar lógica de estado con un reducer
A medida que tus componentes crecen en complejidad, puede volverse difícil seguir a simple vista todas las formas en que el estado de un componente se actualiza. Por ejemplo, el componente TaskApp
mantiene un array de tasks
(tareas) en el estado y usa tres manejadores de eventos diferentes para agregar, borrar y editar tareas.
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>Prague itinerary</h1> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} onChangeTask={handleChangeTask} onDeleteTask={handleDeleteTask} /> </> ); } let nextId = 3; const initialTasks = [ {id: 0, text: 'Visit Kafka Museum', done: true}, {id: 1, text: 'Watch a puppet show', done: false}, {id: 2, text: 'Lennon Wall pic', done: false}, ];
Cada uno de estos manejadores de eventos llama a setTasks
con el fin de actualizar el estado. A medida que el componente crece, también lo hace la cantidad de lógica de estado esparcida a lo largo de este. Para reducir esta complejidad y mantener toda la lógica en un lugar de fácil acceso, puedes mover esa lógica de estado a una función única fuera del componente llamada un «reducer».
Los reducers son una forma diferente de manejar el estado. Puedes migrar de useState
a useReducer
en tres pasos:
- Cambia de asignar un estado a despachar acciones.
- Escribe una función reducer.
- Usa el reducer desde tu componente.
Paso 1: Cambia de establecer un estado a despachar acciones
Tus manejadores de eventos actualmente especifican qué hacer al asignar el estado:
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));
}
Elimina toda la lógica de asignación de estado. Lo que queda son estos tres manejadores de eventos:
handleAddTask(text)
se llama cuando el usuario presiona «Add».handleChangeTask(task)
se llama cuando el usuario cambia una tarea o presiona «Save».handleDeleteTask(taskId)
se llama cuando el usuario presiona «Delete».
Manejar el estado con reducers es ligeramente diferente a asignar directamente el estado. En lugar de decirle a React «qué hacer» al asignar el estado, especificas «qué acaba de hacer el usuario» despachando «acciones» desde tus manejadores de eventos. (¡La lógica de actualización de estado estará en otro lugar!) Entonces, en lugar de «asignar tasks
» a través de un manejador de eventos, estás despachando una acción de «tarea agregada/cambiada/borrada (added/changed/deleted)«. Esta forma describe más la intención del usuario.
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,
});
}
El objeto que pasas a dispatch
se denomina «acción»:
function handleDeleteTask(taskId) {
dispatch(
// objeto "acción":
{
type: 'deleted',
id: taskId,
}
);
}
Es un objeto regular de JavaScript. Tú decides qué poner dentro, pero generalmente debe contener la mínima información acerca de qué ocurrió. (Agregarás la función dispatch
en un paso posterior).
Paso 2: Escribe una función reducer
Una función reducer es donde pondrás tu lógica de estado. Recibe dos argumentos, el estado actual y el objeto de acción, y devuelve el próximo estado.
function yourReducer(state, action) {
// devuelve el próximo estado para que React lo asigne
}
React asignará el estado a lo que se devuelve desde el reducer.
Para mover la lógica de asignación de estado desde tus manejadores de eventos a una función reducer en este ejemplo, vas a:
- Declarar el estado actual (
tasks
) como primer argumento. - Declarar el objeto
action
como segundo argumento. - Devolver el próximo estado desde el reducer (con el cual React asignará el estado).
Aquí se encuentra toda la lógica de asignación de estado migrada a una función reducer:
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('Unknown action: ' + action.type);
}
}
Como la función reducer recibe el estado (
tasks
) como un argumento, puedes declararlo fuera de tu componente. Esto reduce el nivel de tabulación y puede hacer que tu código sea más fácil de leer.
Profundizar
Aunque los reducers pueden «reducir» la cantidad de código dentro de tu componente, son en realidad llamados así por la operación reduce()
la cual se puede realizar en arrays.
La operación reduce()
permite tomar un array y «acumular» un único valor a partir de varios:
const arr = [1, 2, 3, 4, 5];
const sum = arr.reduce(
(result, number) => result + number
); // 1 + 2 + 3 + 4 + 5
La función que se pasa para reducir
es conocida como un «reducer». Toma el resultado hasta el momento y el elemento actual, luego devuelve el siguiente resultado. Los reducers de React son un ejemplo de la misma idea: toman el estado hasta el momento y la acción, y devuelven el siguiente estado. De esta manera, se acumulan acciones sobre el tiempo en estado.
Puedes incluso utilizar el método reduce()
con un estado inicial (initialState
) y un array de acciones (actions
) para calcular el estado final pasándole tu función reducer:
import tasksReducer from './tasksReducer.js'; let initialState = []; let actions = [ {type: 'added', id: 1, text: 'Visit Kafka Museum'}, {type: 'added', id: 2, text: 'Watch a puppet show'}, {type: 'deleted', id: 1}, {type: 'added', id: 3, text: 'Lennon Wall pic'}, ]; let finalState = actions.reduce(tasksReducer, initialState); const output = document.getElementById('output'); output.textContent = JSON.stringify(finalState, null, 2);
Probablemente no necesites hacer esto por tu cuenta, pero ¡es similar a lo que hace React!
Paso 3: Usa el reducer desde tu componente
Finalmente, debes conectar el tasksReducer
a tu componente. Asegúrate de importar el Hook useReducer
de React:
import { useReducer } from 'react';
Luego puedes reemplazar useState
:
const [tasks, setTasks] = useState(initialTasks);
con useReducer
de esta manera:
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
El Hook useReducer
es similar a useState
; debes pasar un estado inicial y devuelve un valor de estado y una manera de actualizar estado (en este caso, la función dispatch). Pero es un poco diferente.
El Hook useReducer
toma dos parámetros:
- Una función reducer
- Un estado inicial
Y devuelve:
- Un valor de estado
- Una función dispatch (para «despachar» acciones del usuario hacia el reducer)
¡Ahora está completamente conectado! Aquí, el reducer se declara al final del archivo del componente:
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>Prague itinerary</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: 'Visit Kafka Museum', done: true}, {id: 1, text: 'Watch a puppet show', done: false}, {id: 2, text: 'Lennon Wall pic', done: false}, ];
Si lo deseas, puedes incluso mover el reducer a un archivo diferente:
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>Prague itinerary</h1> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} onChangeTask={handleChangeTask} onDeleteTask={handleDeleteTask} /> </> ); } let nextId = 3; const initialTasks = [ {id: 0, text: 'Visit Kafka Museum', done: true}, {id: 1, text: 'Watch a puppet show', done: false}, {id: 2, text: 'Lennon Wall pic', done: false}, ];
La lógica del componente puede ser más sencilla de leer cuando separas conceptos como este. Ahora los manejadores de eventos sólo especifican qué ocurrió despachando acciones, y la función reducer determina cómo se actualiza el estado en respuesta a ellas.
Comparación de useState
y useReducer
¡Los reducers no carecen de desventajas! Aquí hay algunas maneras en las que puedas compararlos:
- Tamaño del código: Generalmente, con
useState
debes escribir menos código por adelantado. ConuseReducer
, debes escribir la función de reducer y las actions a despachar. Sin embargo,useReducer
puede ayudar a disminuir el código si demasiados manejadores de eventos modifican el estado de una manera similar - Legibilidad:
useState
es muy sencillo de leer cuando las actualizaciones de estado son simples. Cuando se vuelven más complejas, pueden inflar el código de tu componente y hacerlo difícil de escanear. En este caso,useReducer
te permite separar limpiamente el cómo de la lógica de actualización del que ocurrió de los manejadores de eventos. - Depuración: Cuando tienes un error con
useState
, puede ser difícil decir dónde el estado se ha actualizado incorrectamente, y por qué. ConuseReducer
, puedes agregar un log de la consola en tu reducer para ver cada actualización de estado, y por qué ocurrió (debido a qué acción). Si cada acción es correcta, sabrás que el error se encuentra en la propia lógica del reducer. Sin embargo, debes pasar por más código que conuseState
. - Pruebas: Un reducer es una función pura que no depende de tu componente. Esto significa que puedes exportarla y probarla separadamente de manera aislada. Mientras que generalmente es mejor probar componentes en un entorno más realista, para actualizaciones de estado complejas, puede ser útil asegurar que tu reducer devuelve un estado particular para un estado y acción particular.
- Preferencia personal: Algunas personas prefieren reducers, otras no. Está bien. Es una cuestión de preferencia. Siempre puedes convertir entre
useState
yuseReducer
de un lado a otro: ¡son equivalentes!
Recomendamos utilizar un reducer si a menudo encuentras errores debidos a actualizaciones incorrectas de estado en algún componente, y deseas introducir más estructura a tu código. No es necesario usar reducers para todo: ¡siente la libertad de mezclar y combinar! Incluso puedes tener useState
y useReducer
en el mismo componente.
Escribir reducers correctamente
Ten en cuenta estos dos consejos al escribir reducers:
- Los reducers deben ser puros. Al igual que las funciones de actualización de estado, los reducers ¡se ejecutan durante el renderizado! (Las actions se ponen en cola hasta el siguiente renderizado) Esto significa que los reducers deben ser puros —la misma entrada siempre produce el mismo resultado—. No deben enviar peticiones de red, programar timeouts, o realizar ningún tipo de efecto secundario (operaciones con impacto fuera del componente). Deben actualizar objetos y arrays sin mutaciones.
- Cada acción describe una única interacción del usuario, incluso si eso conduce a múltiples cambios en los datos. Por ejemplo, si un usuario presiona «Reset» en un formulario con cinco campos manejados por un reducer, tiene más sentido despachar una acción
reset_form
en lugar de cinco acciones deset_field
. Si registras cada acción en un reducer, ese registro debería ser suficientemente claro como para reconstruir qué interacciones o respuestas pasaron y en que orden. ¡Esto ayuda en la depuración!
Escribir reducers concisos con Immer
Al igual que para actualizar objetos y arrays, para el estado regular se puede utilizar la biblioteca Immer para hacer los reducers más concisos. Aquí, useImmerReducer
te permite mutar el estado con push
o una asignación arr[i] =
:
import { useImmerReducer } from 'use-immer'; import AddTask from './AddTask.js'; import TaskList from './TaskList.js'; function tasksReducer(draft, action) { switch (action.type) { case 'added': { draft.push({ id: action.id, text: action.text, done: false, }); break; } case 'changed': { const index = draft.findIndex((t) => t.id === action.task.id); draft[index] = action.task; break; } case 'deleted': { return draft.filter((t) => t.id !== action.id); } default: { throw Error('Unknown action: ' + action.type); } } } export default function TaskApp() { const [tasks, dispatch] = useImmerReducer(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>Prague itinerary</h1> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} onChangeTask={handleChangeTask} onDeleteTask={handleDeleteTask} /> </> ); } let nextId = 3; const initialTasks = [ {id: 0, text: 'Visit Kafka Museum', done: true}, {id: 1, text: 'Watch a puppet show', done: false}, {id: 2, text: 'Lennon Wall pic', done: false}, ];
Los reducers deben ser puros, así que no deberían mutar estado. Pero Immer te proporciona un objeto especial draft
que se puede mutar con seguridad. Por detrás, Immer creará una copia de tu estado con los cambios que has hecho a este objeto draft
. Esta es la razón por la que los reducers manejados con useImmerReducer
pueden mutar su primer argumento y no necesitan devolver un estado.
Recapitulación
- Para convertir de
useState
auseReducer
:- Despacha acciones desde manejadores de eventos.
- Escribe una función reducer que devuelve el siguiente estado para un estado y acción dados.
- Reemplaza
useState
conuseReducer
.
- Los reducers requieren que escribas un poco más de código, pero ayudan con la depuración y las pruebas.
- Los reducers deben ser puros.
- Cada acción describe una interacción única del usuario.
- Usa Immer si deseas escribir reducers como si se estuviera mutando el estado.
Desafío 1 de 4: Despachar actions desde manejadores de eventos
Actualmente, los manejadores de eventos en ContactList.js
y Chat.js
tienen comentarios // TODO
. Esta es la razón por la que escribir en el input no funciona, y hacer clic sobre los botones no cambia el destinatario seleccionado.
Reemplaza estos dos // TODO
s con el código para hacer dispatch
de las actions correspondientes. Para ver la forma y el tipo (type) esperados de las acciones, revisa el reducer en messengerReducer.js
. El reducer ya está escrito, así que no necesitas cambiarlo. Solo tendrás que despachar las acciones en ContactList.js
y 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: 'Taylor', email: 'taylor@mail.com'}, {id: 1, name: 'Alice', email: 'alice@mail.com'}, {id: 2, name: 'Bob', email: 'bob@mail.com'}, ];