Mejores reductores con Immer
Publicado: 2022-03-10Como desarrollador de React, ya debería estar familiarizado con el principio de que el estado no debe mutar directamente. Quizás se pregunte qué significa eso (la mayoría de nosotros teníamos esa confusión cuando comenzamos).
Este tutorial hará justicia a eso: comprenderá qué es el estado inmutable y la necesidad de este. También aprenderá a usar Immer para trabajar con estado inmutable y los beneficios de usarlo. Puede encontrar el código en este artículo en este repositorio de Github.
Inmutabilidad en JavaScript y por qué es importante
Immer.js es una pequeña biblioteca de JavaScript escrita por Michel Weststrate, cuya misión declarada es permitirle "trabajar con estado inmutable de una manera más conveniente".
Pero antes de sumergirnos en Immer, repasemos rápidamente la inmutabilidad en JavaScript y por qué es importante en una aplicación React.
El último estándar ECMAScript (también conocido como JavaScript) define nueve tipos de datos integrados. De estos nueve tipos, hay seis que se denominan valores/tipos primitive
. Estas seis primitivas son undefined
, number
, string
, boolean
, bigint
y symbol
. Una simple verificación con el operador typeof
de JavaScript revelará los tipos de estos tipos de datos.
console.log(typeof 5) // number console.log(typeof 'name') // string console.log(typeof (1 < 2)) // boolean console.log(typeof undefined) // undefined console.log(typeof Symbol('js')) // symbol console.log(typeof BigInt(900719925474)) // bigint
Una primitive
es un valor que no es un objeto y no tiene métodos. Lo más importante para nuestra presente discusión es el hecho de que el valor de una primitiva no se puede cambiar una vez que se crea. Por lo tanto, se dice que las primitivas son immutable
.
Los tres tipos restantes son null
, object
y function
. También podemos verificar sus tipos usando el operador typeof
.
console.log(typeof null) // object console.log(typeof [0, 1]) // object console.log(typeof {name: 'name'}) // object const f = () => ({}) console.log(typeof f) // function
Estos tipos son mutable
. Esto significa que sus valores se pueden cambiar en cualquier momento después de su creación.
Quizás se pregunte por qué tengo la matriz [0, 1]
ahí arriba. Bueno, en JavaScriptland, una matriz es simplemente un tipo especial de objeto. En caso de que también se esté preguntando acerca de null
y en qué se diferencia de undefined
. undefined
simplemente significa que no hemos establecido un valor para una variable, mientras que null
es un caso especial para los objetos. Si sabe que algo debería ser un objeto pero el objeto no está allí, simplemente devuelve null
.
Para ilustrar con un ejemplo simple, intente ejecutar el siguiente código en la consola de su navegador.
console.log('aeiou'.match(/[x]/gi)) // null console.log('xyzabc'.match(/[x]/gi)) // [ 'x' ]
String.prototype.match
debería devolver una matriz, que es un tipo de object
. Cuando no puede encontrar dicho objeto, devuelve null
. Devolver undefined
tampoco tendría sentido aquí.
Suficiente con eso Volvamos a discutir la inmutabilidad.
De acuerdo con los documentos de MDN:
"Todos los tipos, excepto los objetos, definen valores inmutables (es decir, valores que no se pueden cambiar)".
Esta declaración incluye funciones porque son un tipo especial de objeto de JavaScript. Ver definición de función aquí.
Echemos un vistazo rápido a lo que significan en la práctica los tipos de datos mutables e inmutables. Intente ejecutar el siguiente código en la consola de su navegador.
let a = 5; let b = a console.log(`a: ${a}; b: ${b}`) // a: 5; b: 5 b = 7 console.log(`a: ${a}; b: ${b}`) // a: 5; b: 7
Nuestros resultados muestran que aunque b
se "deriva" de a
, cambiar el valor de b
no afecta el valor de a
. Esto surge del hecho de que cuando el motor de JavaScript ejecuta la declaración b = a
, crea una nueva ubicación de memoria separada, coloca 5
allí y apunta b
en esa ubicación.
¿Qué pasa con los objetos? Considere el siguiente código.
let c = { name: 'some name'} let d = c; console.log(`c: ${JSON.stringify(c)}; d: ${JSON.stringify(d)}`) // {"name":"some name"}; d: {"name":"some name"} d.name = 'new name' console.log(`c: ${JSON.stringify(c)}; d: ${JSON.stringify(d)}`) // {"name":"new name"}; d: {"name":"new name"}
Podemos ver que cambiar la propiedad del nombre a través de la variable d
también la cambia en c
. Esto surge del hecho de que cuando el motor JavaScript ejecuta la instrucción c = { name: 'some name
'
}
, el motor JavaScript crea un espacio en la memoria, coloca el objeto dentro y apunta c
hacia él. Luego, cuando ejecuta la declaración d = c
, el motor de JavaScript simplemente apunta d
a la misma ubicación. No crea una nueva ubicación de memoria. Por lo tanto, cualquier cambio en los elementos de d
es implícitamente una operación en los elementos de c
. Sin mucho esfuerzo, podemos ver por qué esto es un problema en ciernes.
Imagina que estás desarrollando una aplicación React y en algún lugar quieres mostrar el nombre del usuario como some name
leyendo de la variable c
. Pero en otro lugar, introdujo un error en su código al manipular el objeto d
. Esto daría como resultado que el nombre del usuario apareciera como new name
. Si c
y d
fueran primitivas no tendríamos ese problema. Pero las primitivas son demasiado simples para los tipos de estado que debe mantener una aplicación típica de React.
Esta es una de las principales razones por las que es importante mantener un estado inmutable en su aplicación. Le animo a que revise algunas otras consideraciones leyendo esta breve sección del LÉAME de Immutable.js: el caso de la inmutabilidad.
Habiendo entendido por qué necesitamos la inmutabilidad en una aplicación React, ahora veamos cómo Immer aborda el problema con su función de produce
.
Función de produce
de Immer
La API principal de Immer es muy pequeña y la función principal con la que trabajará es la función de produce
. produce
simplemente toma un estado inicial y una devolución de llamada que define cómo se debe mutar el estado. La devolución de llamada en sí recibe una copia preliminar (idéntica, pero aún una copia) del estado en el que realiza todas las actualizaciones previstas. Finalmente, produce
un nuevo estado inmutable con todos los cambios aplicados.
El patrón general para este tipo de actualización de estado es:
// produce signature produce(state, callback) => nextState
Veamos cómo funciona esto en la práctica.
import produce from 'immer' const initState = { pets: ['dog', 'cat'], packages: [ { name: 'react', installed: true }, { name: 'redux', installed: true }, ], } // to add a new package const newPackage = { name: 'immer', installed: false } const nextState = produce(initState, draft => { draft.packages.push(newPackage) })
En el código anterior, simplemente pasamos el estado inicial y una devolución de llamada que especifica cómo queremos que sucedan las mutaciones. Es tan simple como eso. No necesitamos tocar ninguna otra parte del estado. Deja initState
intacto y comparte estructuralmente aquellas partes del estado que no tocamos entre el estado inicial y el nuevo. Una de esas partes en nuestro estado es la matriz de pets
. El produce
d nextState
es un árbol de estado inmutable que tiene los cambios que hemos realizado, así como las partes que no modificamos.
Armados con este conocimiento simple pero útil, echemos un vistazo a cómo los produce
pueden ayudarnos a simplificar nuestros reductores React.
Reductores De Escritura Con Immer
Supongamos que tenemos el objeto de estado definido a continuación
const initState = { pets: ['dog', 'cat'], packages: [ { name: 'react', installed: true }, { name: 'redux', installed: true }, ], };
Y queríamos agregar un nuevo objeto y, en un paso posterior, establecer su clave installed
en true
const newPackage = { name: 'immer', installed: false };
Si tuviéramos que hacer esto de la manera habitual con el objeto de JavaScript y la sintaxis de distribución de matriz, nuestro reductor de estado podría verse como a continuació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; } };
Podemos ver que esto es innecesariamente detallado y propenso a errores para este objeto de estado relativamente simple. También tenemos que tocar cada parte del estado, lo cual es innecesario. Veamos cómo podemos simplificar esto con Immer.
const updateReducerWithProduce = (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.filter(p => p.name === action.name)[0]; if (package) package.installed = action.installed; break; } default: break; } });
Y con unas pocas líneas de código, hemos simplificado enormemente nuestro reductor. Además, si caemos en el caso predeterminado, Immer simplemente devuelve el estado de borrador sin que tengamos que hacer nada. Observe cómo hay menos código repetitivo y la eliminación de la propagación del estado. Con Immer, solo nos preocupamos de la parte del estado que queremos actualizar. Si no podemos encontrar dicho elemento, como en la acción `UPDATE_INSTALLED`, simplemente seguimos adelante sin tocar nada más. La función `producir` también se presta al curry. Pasar una devolución de llamada como el primer argumento para `producir` está destinado a usarse para curry. La firma del "producto" al curry es //curried produce signature produce(callback) => (state) => nextState
Veamos cómo podemos actualizar nuestro estado anterior con un producto al curry. Nuestros productos al curry se verían así: const curriedProduce = produce((draft, action) => { switch (action.type) { case 'ADD_PACKAGE': draft.packages.push(action.package); break; case 'SET_INSTALLED': { const package = draft.packages.filter(p => p.name === action.name)[0]; if (package) package.installed = action.installed; break; } default: break; } });
La función de producción al curry acepta una función como su primer argumento y devuelve una producción al curry que solo ahora requiere un estado desde el cual producir el siguiente estado. El primer argumento de la función es el estado de borrador (que se derivará del estado que se pasará al llamar a este producto con curry). Luego sigue cada número de argumentos que deseamos pasar a la función.
Todo lo que necesitamos hacer ahora para usar esta función es pasar el estado desde el cual queremos producir el siguiente estado y el objeto de acción como tal.
// add a new package to the starting state const nextState = curriedProduce(initState, { type: 'ADD_PACKAGE', package: newPackage, }); // update an item in the recently produced state const nextState2 = curriedProduce(nextState, { type: 'SET_INSTALLED', name: 'immer', installed: true, });
Tenga en cuenta que en una aplicación React cuando usamos el useReducer
, no necesitamos pasar el estado explícitamente como lo hice anteriormente porque se encarga de eso.
Quizás se pregunte, ¿Immer obtendría un hook
, como todo en React en estos días? Bueno, estás en compañía con buenas noticias. Immer tiene dos ganchos para trabajar con el estado: los useImmer
y useImmerReducer
. Veamos cómo funcionan.
Uso de los ganchos useImmer
y useImmerReducer
La mejor descripción del gancho useImmer
proviene del mismo README de use-immer.
useImmer(initialState)
es muy similar auseState
. La función devuelve una tupla, el primer valor de la tupla es el estado actual, el segundo es la función de actualización, que acepta una función de productor immer, en la que eldraft
se puede mutar libremente, hasta que el productor finalice y se realicen los cambios. inmutable y convertirse en el siguiente estado.
Para hacer uso de estos ganchos, debe instalarlos por separado, además de la biblioteca principal de Immer.
yarn add immer use-immer
En términos de código, el useImmer
se ve a continuación
import React from "react"; import { useImmer } from "use-immer"; const initState = {} const [ data, updateData ] = useImmer(initState)
Y es tan simple como eso. Se podría decir que es useState de React pero con un poco de esteroide. Utilizar la función de actualización es muy sencillo. Recibe el estado de borrador y puede modificarlo tanto como desee, como se muestra a continuación.
// make changes to data updateData(draft => { // modify the draft as much as you want. })
El creador de Immer ha proporcionado un ejemplo de codesandbox con el que puedes jugar para ver cómo funciona.
useImmerReducer
es igualmente fácil de usar si ha usado el gancho useReducer
de React. Tiene una firma similar. Veamos cómo se ve eso en términos de código.
import React from "react"; import { useImmerReducer } from "use-immer"; const initState = {} const reducer = (draft, action) => { switch(action.type) { default: break; } } const [data, dataDispatch] = useImmerReducer(reducer, initState);
Podemos ver que el reductor recibe un estado draft
el cual podemos modificar tanto como queramos. También hay un ejemplo de codesandbox aquí para que experimentes.
Y así de sencillo es utilizar ganchos Immer. Pero en caso de que aún se pregunte por qué debería usar Immer en su proyecto, aquí hay un resumen de algunas de las razones más importantes que encontré para usar Immer.
Por qué debería usar Immer
Si ha escrito la lógica de administración de estado durante algún tiempo, apreciará rápidamente la simplicidad que ofrece Immer. Pero ese no es el único beneficio que ofrece Immer.
Cuando usa Immer, termina escribiendo menos código repetitivo como hemos visto con reductores relativamente simples. Esto también hace que las actualizaciones profundas sean relativamente fáciles.
Con bibliotecas como Immutable.js, debe aprender una nueva API para obtener los beneficios de la inmutabilidad. Pero con Immer se logra lo mismo con Objects
, Arrays
, Sets
y Maps
de JavaScript normales. No hay nada nuevo que aprender.
Immer también proporciona el uso compartido estructural de forma predeterminada. Esto simplemente significa que cuando realiza cambios en un objeto de estado, Immer comparte automáticamente las partes sin cambios del estado entre el estado nuevo y el estado anterior.
Con Immer, también obtiene la congelación automática de objetos, lo que significa que no puede realizar cambios en el estado produced
. Por ejemplo, cuando comencé a usar Immer, traté de aplicar el método de sort
en una matriz de objetos devueltos por la función de producción de Immer. Lanzó un error diciéndome que no puedo hacer ningún cambio en la matriz. Tuve que aplicar el método de segmento de matriz antes de aplicar sort
. Una vez más, el nextState
producido es un árbol de estado inmutable.
Immer también está fuertemente tipado y es muy pequeño con solo 3 KB cuando se comprime con gzip.
Conclusión
Cuando se trata de administrar actualizaciones de estado, usar Immer es una obviedad para mí. Es una biblioteca muy liviana que le permite seguir usando todo lo que ha aprendido sobre JavaScript sin intentar aprender algo completamente nuevo. Te animo a instalarlo en tu proyecto y comenzar a usarlo de inmediato. Puede agregarlo en proyectos existentes y actualizar gradualmente sus reductores.
También lo animo a leer la publicación de blog introductoria de Immer de Michael Weststrate. La parte que encuentro especialmente interesante es "¿Cómo funciona Immer?" sección que explica cómo Immer aprovecha las características del lenguaje, como los proxies y conceptos como la copia en escritura.
También lo animo a que eche un vistazo a esta publicación de blog: Inmutabilidad en JavaScript: una visión contraria, donde el autor, Steven de Salas, presenta sus pensamientos sobre los méritos de buscar la inmutabilidad.
Espero que con las cosas que has aprendido en esta publicación puedas comenzar a usar Immer de inmediato.
Recursos Relacionados
-
use-immer
, GitHub - Immer, GitHub
-
function
, documentos web de MDN, Mozilla -
proxy
, documentos web de MDN, Mozilla - Objeto (informática), Wikipedia
- "Inmutabilidad en JS", Orji Chidi Matthew, GitHub
- "Valores y tipos de datos de ECMAScript", Ecma International
- Colecciones inmutables para JavaScript, Immutable.js, GitHub
- "El caso de la inmutabilidad", Immutable.js, GitHub