Lidiando con reducers e inmutabilidad de forma declarativa

Publicado: 16/05/2021 · Tiempo de lectura: 6 minutos

Este artículo fue publicado originalmente aquí

Todas hemos escrito reducers de Redux que tienen mil millones de usos del operador spread (...). Nos alegramos cuando funcionan pero no podemos evitar sentirnos un poco mal, dada la verbosidad y el exceso de boilerplate.

Voy a tomar prestado un ejemplo de este artículo por Chidi Orji en Smashing Magazine, para mostrar a qué tipo de reducer me estoy refiriendo, en caso de que seas suficientemente afortunado de nunca haberte encontrado con uno de estos monstruos en código de producción:

const updateReducer = (state = initState, action) => {
switch (action.type) {
case 'ADD_PACKAGE':
return {
...state,
packages: [...state.packages, action.package],
};
case 'UPDATE_INSTALLED':
return {
...state,
packages: state.packages.map(pack =>
pack.name === action.name
? { ...pack, installed: action.installed }
: pack
),
};
default:
return state;
}
};

Este no es tan terrible para ser honesto ¡Créanme, he visto y escrito peores!

¿Cuál es el problema aquí, exactamente? Bueno, si queremos actualizar una propiedad de algún objeto que está muy anidado en nuestro árbol de estado, vamos a tener que copiar mucho estado manualmente para modificarlo y luego retornar nuevo estado. Recordemos: los reducers deben ser funciones puras libres de efectos secundarios, por lo que no podemos realizar mutaciones directamente sobre el estado, sino que retornar una referencia distinta para gatillar una transición.

El problema con usar mucho el operador spread (...) en términos de legibilidad y mantenibilidad, es que no nos ayuda a expresar ninguna intención sobre cómo vamos a actualizar nuestro estado. Sólo nos ayuda a copiar cosas, y para cuando llegamos a la propiedad que queremos actualizar, terminamos con un montón de código a nuestro alrededor que no sirve ningún propósito más que replicar la estructura del árbol de estado.

Haciendo las cosas declarativas

Una de las cosas que los conceptos de programación funcional embebidos en React nos han enseñado, es que expresar intención de forma declarativa hace nuestro código más fácil de entender. Lo hace más fácil para nosotras en el futuro y para nuestras colegas, porque cuando escribimos código lo hacemos para otras personas. Si eso no fuera imporante, escribiríamos assembly code o byte code y los lenguajes de programación de alto nivel no existirían.

Así que en vez de copiar cosas manualmente y luego intentar descifrar qué es lo que el código que escribimos hace supuestamente, es mejor si realmente describimos lo que el código debería hacer. Y la mejor manera de hacerlo de forma declarativa, es utilizando funciones puras.

Démosle un vistazo a cómo la acción 'ADD_PACKAGE' está actualizando el estado en nuestro ejemplo anterior:

const updateReducer = (state = initState, action) => {
switch (action.type) {
case 'ADD_PACKAGE':
return {
...state,
packages: [...state.packages, action.package],
};
// ...etc
}
};

Bastante simple, ¿cierto? Un valor nuevo es asociado a la propiedad packages, y obtenemos este valor tras adjuntar un nuevo paquete al listado de paquetes.

Definamos algunas funciones para realizar estas operaciones. Primero, definiremos una función llamada assoc, que nos ayude a asociar data a una propiedad:

const assoc = (key, value, record) => ({
...record,
[key]: value
});

Y luego una función append, para adjuntar un valor al final de una lista (sería una versión inmutable de Array.prototype.push):

const append = (element, list) => [ ...list, element ];

Podemos usar ambas funciones para actualizar nuestro estado:

const updateReducer = (state = initState, action) => {
switch (action.type) {
case 'ADD_PACKAGE':
return assoc(
'packages’,
append(action.package, state.packages),
state
);
// ...etc
}
};

Ahora, para un caso tan simple como el anterior los beneficios no son realmente visibles. Veamos que sucede con la acción 'UPDATE_INSTALLED':

const updateReducer = (state = initState, action) => {
switch (action.type) {
case 'UPDATE_INSTALLED':
return {
...state,
packages: state.packages.map(pack =>
pack.name === action.name
? { ...pack, installed: action.installed }
: pack
),
};
// ...etc
}
};

En este caso queremos ajustar (o asociar un nuevo valor) la propiedad installed de un paquete solo si el nombre del paquete (pack.name) es igual a action.name. Ahora, si bien podemos utilizar map para actualizar el listado de paquetes, esto es conceptuamente erróneo.

Cuando usamos map, todos los valores de la colección deberían ser transformados. Decidir si queremos transformar un elemento o no dentro de la colección, es un comportamiento especializado y no particularmente un mapeo: estamos realizando una actualización selectiva.

Hay muchas formas de manejar condicionalidad en el código: la más común es utilizando un if. Pero hay mejores formas de manejar condicionalidad, y si la que intentamos modelar implica un cambio de comportamiento, lo que en realidad necesitamos es un comportamiento especializado.

Dicho esto, entonces creemos una función especializada que describa este comportamiento de forma más precisa:

const adjustByWhere = (adjustFn, selectFn, list) => {
return list.map((acc, item) => {
return selectFn(item) ? adjustFn(item) : item;
});
};

Probablemente estás pensando: "Trampa! Eso es lo mismo que tienes en el código del reducer, solo que genérico". Y sí, es la misma cosa. La diferencia es que ahora tenemos una abstracción que realmente describe la operación que estamos realizando, y ese es todo el punto de hacer programación declarativa: que tu código describa lo que hace, en vez de tener que descifrarlo nosotras cada vez que lo leemos.

Nuestra acción luciría así:

const updateReducer = (state = initState, action) => {
switch (action.type) {
case 'UPDATE_INSTALLED':
return assoc(
'packages’,
adjustByWhere(
package => assoc('installed', action.installed, package),
package => package.name === 'foo',
state.packages
),
state
);
// ...etc
}
};

Esta solución está lejos de ser perfecta y definitivamente hay mejoras que podríamos aplicar a la API, pero la idea está allí: comenzar a escribir código que te diga lo que está haciendo, en vez de tener que descifrarlo cada vez que lo lees.

El elefante en la habitación

Me sentiría sumamente irresponsable si no mencionara Immer, porque resuelve este problema de forma brillante. Hablaré de ello brevemente pero si quieres darle un vistazo a fondo, asegúrate de leer la documentación.

Como diría Miley, Immer llegó como una bola de demolición para ofrecer una solución alternativa a este problema: en vez de hacernos copiar manualmente el estado actual de la aplicación para retornar nuevo estado, Immer nos proporciona un borrador que podemos actualizar directamente, mutando cualquier propiedad que queramos. Tras finalizar con las mutaciones, Immer crea un nuevo estado de forma inmutable usando el borrador que hemos modificado como base. Ese es un truco bastante inteligente, si me preguntas (o si le preguntas a cualquier, es brillante).

El código previo se vería así:

import { produce } from 'immer';
const updateReducer = (state = initState, action) =>
produce(state, draft => {
switch (action.type) {
case 'ADD_PACKAGE': {
draft.packages.push(action.package);
break;
}
case 'UPDATE_INSTALLED': {
const package = draft.packages.find(p => p.name === action.name);
if (package) package.installed = action.installed;
break;
}
default: {
break;
}
}
});

Ahora, esto luce mucho más simple. Sin embargo hay una trampa: cuando usamos Immer debemos seguir un par de reglas porque así es como funcionan las cosas mágicas.

En resumen:

  • Puedes modificar el estado o retornar un nuevo objet. Si haces ambas cosas, el infierno se desata.
  • No podemos asignar directamente un valor al borrador. Podemos cambiar cualquier cosa dentro del borrador (cualquier propiedad de este), pero no el borrador en sí.

Si quieres averiguar más al respecto, checa este inciso en la documentación.


Y eso ha sido todo por hoy. Si te ha gustado el contenido, no te olvides de darle una compartida en Twitter y seguirme por ahí.

Gracias totales y hasta la próxima.