Extrayendo lógica de estado en un reducer

Los componentes con muchas actualizaciones de estado distribuidas a través de varios event handlers 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

  • Que es una función de reducer
  • Como refactorizar de useState a useReducer
  • Cuando utilizar un reducer
  • Como escribir uno de manera correcta

Consolidando lógica de estado con un reducer

A medida que tus componentes crecen en complejidad, puede volverse difícil a simple vista todas las maneras en las cuales el estado de un componente es actualizado. Por ejemplo el componente TaskApp mantiene un array de tasks en estado y usa tres event handlers diferentes para agregar, borrar y editar tasks.

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 event handlers 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 éste. 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:

  1. Cambia de actualizar un estado a despachar actions.
  2. Escribe una función de reducer.
  3. Usa el reducer desde tu componente.

Paso 1: Cambia de establecer un estado a despachar actions

Tus event handlers actualmente especifican que hacer al actualizar un 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 actualización de estado. Lo que queda son estos tres event handlers:

  • handleAddTask(text) es llamado cuando el usuario presiona “Add”.
  • handleChangeTask(task) es llamado cuando el usuario cambia una task o presiona “Save”.
  • handleDeleteTask(taskId) es llamado cuando el usuario presiona “Delete”.

Manejar el estado con reducers es ligeramente diferente a directamente actualizar el estado. En lugar de decirle a React “que hacer” al actualizar el estado, especificas “que acaba de hacer el usuario” despachando “actions” desde tus event handlers. (La lógica de actualización de estado estará en otro lugar!) Entonces en lugar de “actualizar tasks” a través de un event handler, estás despachando una action de “task agregada/cambiada/borrada”. Esto es más descriptivo acerca de 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 es llamado un “action”:

function handleDeleteTask(taskId) {
dispatch(
// objeto de "action":
{
type: 'deleted',
id: taskId,
}
);
}

Es un objeto regular de JavaScript. Tú decides que poner dentro de él, pero generalmente debe contener la mínima información acerca de que ocurrió. (Agregarás la función de dispatch en un paso posterior.)

Nota

Un objeto de action puede tener cualquier forma.

Por convención, es común proporcionar un string type que describe que ocurrió, y transmite cualquier información adicional en otros campos. El type es específico a un componente, en este ejemplo tanto 'added' o 'added_task' estaría bien. ¡Elige un nombre que describa que ocurrió!

dispatch({
// específico al componente
type: 'what_happened',
// otros campos van aquí
});

Paso 2: Escribe una función de reducer

Una función de reducer es donde pondrás tu lógica de estado. Recibe dos argumentos, el estado actual y el objeto de action, y devuelve el siguiente estado.

function yourReducer(state, action) {
// devuelve siguiente estado a React para ser actualizado
}

React actualizará el estado a lo que se devuelve desde el reducer.

Para mover la lógica de actualización de estado desde tus event handlers a una función de reducer en este ejemplo, vas a:

  1. Declarar el estado actual (tasks) como primer argumento.
  2. Declarar el objeto action como segundo argumento.
  3. Devolver el siguiente estado desde el reducer (con el cual React actualizará el estado).

Aquí se encuentra toda la lógica de actualización de estado migrada a una función de 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 de reducer recibe el estado (tasks) como un argumento, puedes declararlo fuera de tu componente. Esto reduce el nivel de indentación y puede hacer que tu código sea más fácil de leer.

Nota

El código arriba usa esta sentencias if/else, pero es una convención usar sentencias switch dentro de los reducers. El resultado es el mismo, pero puede ser más sencillo leer sentencias switch a simple vista.

Las estaremos usando a lo largo del resto de esta documentación de la siguiente manera:

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

Recomendamos envolver cada instancia case entre llaves { y } así las variables declarados dentro de los diferentes cases no conflictúan entre ellas. También, un case debería generalmente terminar con un return. Si olvidas de hacer un return, el codigo “caerá” hasta el siguiente case, lo que puede llevarte a errores!

Si todavía no te sientes cómodo con sentencias switch, utilizar if/else está bien.

Deep Dive

¿Por qué los reducers se llaman de esta manera?

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 item 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 action, y devuelven el siguiente estado. De esta manera, se acumulan actions sobre el tiempo en estado. Puedes incluso utilizar el método reduce() con un initialState y un array de actions para calcular el estado final pasándole tu función de reducer a él:

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 React hace!

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 con estado y una manera de actualizar estado (en este caso, la funcion de dispatch). Pero es un poco diferente.

El Hook useReducer toma dos parámetros:

  1. Una función de reducer
  2. Un estado inicial

Y devuelve:

  1. Un valor con estado
  2. Una función de dispatch (para “despachar” acciones del usuario hacia el reducer)

Ahora está completamente conectado! Ahora, el reducer es declarado 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 éste. Ahora los event handlers sólo especifican que ocurrió despachando actions, y la función de reducer determina cómo se actualiza el estado en respuesta a ellos.

Comparando 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. Con useReducer, debes escribir la función de reducer y las actions a despachar. Sin embargo, useReducer puede ayudar a disminuir el código si demasiados event handlers 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 desde el que ocurrió de los event handlers.
  • Depurando: Cuando tienes un error con useState, puede ser difícil decir dónde el estado ha sido actualizado incorrectamente, y por qué. Con useReducer, puedes agregar un log de la consola en tu reducer para ver cada actualización de estado, y por qué ocurrió (debido a qué action). Si cada action 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 con useState.
  • 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 action particular.
  • Preferencia personal: Algunas personas prefieren reducers, otras no. Está bien. Es una cuestión de preferencia. Siempre puedes convertir entre useState y useReducer 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.

Escribiendo reducers de manera correcta

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 signfica que los reducers deben ser puros—misma entrada siempre produce el mismo resultado. No deben enviar requests, programar timeouts, o realizar efectos secundarios (operaciones que impactan fuera del componente). Deben actualizar objetos y arrays sin mutaciones.
  • Cada action 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 action de reset_form en lugar de cinco actions de set_field. Si muestras cada action en un reducer, esto debería ser suficientemente claro como para reconstruir las interacciones o respuestas que pasaron en que orden. Esto ayuda con la depuración!

Escribiendo reducers concisos con Immer

Al igual que al actualizar objetos y arrays en estado regular, se puede utilizar la biblioteca de 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 provee de un objeto especial draft que es seguro de mutar. Por detrás, Immer creará una copia de tu estado con los cambios que has hecho a este objeto draft. Esto 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 a useReducer:
    1. Despachar actions desde event handlers.
    2. Escribir una función de reducer que devuelve el siguiente estado para un estado dado y action.
    3. Reemplazar useState con useReducer.
  • Los reducers requieren que escribas un poco más de código, pero ayudan con la depuración y pruebas
  • Los reducers deben ser puros.
  • Cada action describe una interacción única del usuario.
  • Usa Immer si deseas escribir reducers mutando el estado actual.

Desafío 1 de 4:
Despachar actions desde event handlers

Actualmente, los event handlers en ContactList.js y Chat.js tienen comentarios // TODO. Ésta 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 // TODOs con el código para hacer dispatch de las actions correspondientes. Para ver la forma esperada y el type de las actions, revisa el reducer en messengerReducer.js. El reducer ya está escrito así que no necesitas cambiarlo. Solo tendrás que despachar las actions 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'},
];