Inmutabilidad en JavaScript usando Redux

Publicado: 2022-03-11

En un ecosistema cada vez mayor de aplicaciones JavaScript ricas y complicadas, hay más estados que administrar que nunca: el usuario actual, la lista de publicaciones cargadas, etc.

Cualquier conjunto de datos que necesite un historial de eventos puede considerarse con estado. Administrar el estado puede ser difícil y propenso a errores, pero trabajar con datos inmutables (en lugar de mutables) y ciertas tecnologías de soporte, a saber, Redux, para los fines de este artículo, puede ayudar significativamente.

Los datos inmutables tienen restricciones, a saber, que no se pueden cambiar una vez que se crean, pero también tienen muchos beneficios, particularmente en la igualdad de referencia versus valor, lo que puede acelerar en gran medida las aplicaciones que dependen de la comparación frecuente de datos (comprobando si algo necesita actualizarse). , por ejemplo).

El uso de estados inmutables nos permite escribir código que puede decir rápidamente si el estado ha cambiado, sin necesidad de hacer una comparación recursiva de los datos, que suele ser mucho, mucho más rápido.

Este artículo cubrirá las aplicaciones prácticas de Redux al administrar el estado a través de creadores de acciones, funciones puras, reductores compuestos, acciones impuras con Redux-saga y Redux Thunk y, finalmente, el uso de Redux con React. Dicho esto, hay muchas alternativas a Redux, como las bibliotecas basadas en MobX, Relay y Flux.

¿Por qué Redux?

El aspecto clave que separa a Redux de la mayoría de los otros contenedores de estado, como MobX, Relay y la mayoría de las implementaciones basadas en Flux, es que Redux tiene un solo estado que solo se puede modificar a través de "acciones" (objetos de JavaScript sin formato), que se envían al Tienda redux. La mayoría de los otros almacenes de datos tienen el estado contenido en los propios componentes de React, lo que le permite tener múltiples almacenes y/o usar un estado mutable.

Esto, a su vez, hace que el reductor de la tienda, una función pura que opera con datos inmutables, se ejecute y potencialmente actualice el estado. Este proceso impone un flujo de datos unidireccional, que es más fácil de entender y más determinista.

El flujo Redux.

Dado que los reductores de Redux son funciones puras que operan en datos inmutables, siempre producen la misma salida dada la misma entrada, lo que facilita su prueba. He aquí un ejemplo de un reductor:

 import Immutable from 'seamless-immutable' const initialState = Immutable([]) // create immutable array via seamless-immutable /** * a reducer takes a state (the current state) and an action object (a plain JavaScript object that was dispatched via dispatch(..) and potentially returns a new state. */ function addUserReducer(state = initialState, action) { if (action.type === 'USERS_ADD') { return state.concat(action.payload) } return state // note that a reducer MUST return a value } // somewhere else... store.dispatch({ type: 'USERS_ADD', payload: user }) // dispatch an action that causes the reducer to execute and add the user

Tratar con funciones puras permite que Redux admita fácilmente muchos casos de uso que generalmente no se realizan fácilmente con el estado mutativo, como:

  • Viaje en el tiempo (Retroceder en el tiempo a un estado anterior)
  • Registro (haga un seguimiento de cada acción para descubrir qué causó una mutación en la tienda)
  • Entornos colaborativos (como GoogleDocs, donde las acciones son objetos simples de JavaScript y pueden serializarse, enviarse por cable y reproducirse en otra máquina)
  • Fácil informe de errores (simplemente envíe la lista de acciones enviadas y reprodúzcalas para obtener exactamente el mismo estado)
  • Representación optimizada (al menos en marcos que representan DOM virtual en función del estado, como React: debido a la inmutabilidad, puede saber fácilmente si algo ha cambiado comparando referencias, en lugar de comparar los objetos de forma recursiva)
  • Pruebe fácilmente sus reductores, ya que las funciones puras se pueden probar fácilmente.

Creadores de acciones

Los creadores de acciones de Redux ayudan a mantener el código limpio y comprobable. Recuerde que las "acciones" en Redux no son más que objetos simples de JavaScript que describen una mutación que debería ocurrir. Dicho esto, escribir los mismos objetos una y otra vez es repetitivo y propenso a errores.

Un creador de acciones en Redux es simplemente una función auxiliar que devuelve un objeto JavaScript simple que describe una mutación. Esto ayuda a reducir el código repetitivo y mantiene todas sus acciones en un solo lugar:

 export function usersFetched(users) { return { type: 'USERS_FETCHED', payload: users, } } export function usersFetchFailed(err) { return { type: 'USERS_FETCH_FAILED', payload: err, } } // reducer somewhere else... const initialState = Immutable([]) // create immutable array via seamless-immutable /** * a reducer takes a state (the current state) and an action object (a plain JavaScript object that was dispatched via dispatch(..) and potentially returns a new state. */ function usersFetchedReducer(state = initialState, action) { if (action.type === 'USERS_FETCHED') { return Immutable(action.payload) } return state // note that a reducer MUST return a value }

Uso de Redux con bibliotecas inmutables

Si bien la naturaleza misma de los reductores y las acciones los hace fáciles de probar, sin una biblioteca de ayuda de inmutabilidad, no hay nada que lo proteja de la mutación de objetos, lo que significa que las pruebas para todos sus reductores deben ser particularmente sólidas.

Considere el siguiente ejemplo de código de un problema con el que se encontrará sin una biblioteca para protegerlo:

 const initialState = [] function addUserReducer(state = initialState, action) { if (action.type === 'USERS_ADD') { state.push(action.payload) // NOTE: mutating action!! return state } return state // note that a reducer MUST return a value }

En este ejemplo de código, el viaje en el tiempo se interrumpirá ya que el estado anterior ahora será el mismo que el estado actual, es posible que los componentes puros no se actualicen (o vuelvan a renderizar) ya que la referencia al estado no ha cambiado a pesar de que los datos contiene ha cambiado, y las mutaciones son mucho más difíciles de razonar.

Sin una biblioteca de inmutabilidad, perdemos todos los beneficios que proporciona Redux. Por lo tanto, se recomienda encarecidamente utilizar una biblioteca auxiliar de inmutabilidad, como immutable.js o inmutable sin fisuras, especialmente cuando se trabaja en un equipo grande con varias manos que tocan el código.

Independientemente de la biblioteca que use, Redux se comportará de la misma manera. Comparemos los pros y los contras de ambos para que pueda elegir el que mejor se adapte a su caso de uso:

Inmutable.js

Immutable.js es una biblioteca, creada por Facebook, con un estilo más funcional que incorpora estructuras de datos, como mapas, listas, conjuntos y secuencias. Su biblioteca de estructuras de datos persistentes inmutables realiza la menor cantidad de copias posible entre diferentes estados.

Ventajas:

  • Intercambio estructural
  • Más eficiente en las actualizaciones
  • Más memoria eficiente
  • Tiene un conjunto de métodos auxiliares para administrar las actualizaciones.

Contras:

  • No funciona a la perfección con las bibliotecas JS existentes (es decir, lodash, ramda)
  • Requiere conversión hacia y desde (toJS/fromJS), especialmente durante la hidratación/deshidratación y el renderizado

Perfecta-inmutable

Seamless-immutable es una biblioteca para datos inmutables que es compatible con versiones anteriores hasta ES5.

Se basa en las funciones de definición de propiedades de ES5, como defineProperty(..) para deshabilitar las mutaciones en los objetos. Como tal, es totalmente compatible con bibliotecas existentes como lodash y Ramda. También se puede deshabilitar en compilaciones de producción, lo que proporciona una ganancia de rendimiento potencialmente significativa.

Ventajas:

  • Funciona a la perfección con las bibliotecas JS existentes (es decir, lodash, ramda)
  • No se necesita código adicional para admitir la conversión
  • Las comprobaciones se pueden desactivar en las compilaciones de producción, lo que aumenta el rendimiento

Contras:

  • Sin intercambio estructural: los objetos/matrices se copian superficialmente, lo que hace que sea más lento para grandes conjuntos de datos
  • No tan eficiente en memoria

Redux y Reductores Múltiples

Otra característica útil de Redux es la capacidad de componer reductores juntos. Esto le permite crear aplicaciones mucho más complicadas, y en una aplicación de cualquier tamaño apreciable, inevitablemente tendrá múltiples tipos de estado (usuario actual, la lista de publicaciones cargadas, etc.). Redux admite (y alienta) este caso de uso al proporcionar naturalmente la función combineReducers :

 import { combineReducers } from 'redux' import currentUserReducer from './currentUserReducer' import postsListReducer from './postsListReducer' export default combineReducers({ currentUser: currentUserReducer, postsList: postsListReducer, })

Con el código anterior, puede tener un componente que se base en currentUser y otro componente que se base en postsList . Esto también mejora el rendimiento, ya que cualquier componente individual solo se suscribirá a cualquier rama del árbol que le concierna.

Acciones impuras en Redux

De forma predeterminada, solo puede enviar objetos de JavaScript sin formato a Redux. Sin embargo, con el middleware, Redux puede admitir acciones impuras, como obtener la hora actual, realizar una solicitud de red, escribir un archivo en el disco, etc.

'Middleware' es el término utilizado para funciones que pueden interceptar acciones que se envían. Una vez interceptado, puede hacer cosas como transformar la acción o enviar una acción asíncrona, al igual que el middleware en otros marcos (como Express.js).

Dos bibliotecas de middleware muy comunes son Redux Thunk y Redux-saga. Redux Thunk está escrito en un estilo imperativo, mientras que Redux-saga está escrito en un estilo funcional. Comparemos ambos.

Thunk redux

Redux Thunk admite acciones impuras dentro de Redux mediante el uso de thunks, funciones que devuelven otras funciones encadenables. Para usar Redux-Thunk, primero debe montar el middleware Redux Thunk en la tienda:

 import { createStore, applyMiddleware } from 'redux' import thunk from 'redux-thunk' const store = createStore( myRootReducer, applyMiddleware(thunk), // here, we apply the thunk middleware to R )

Ahora podemos realizar acciones impuras (como realizar una llamada API) enviando un thunk a la tienda Redux:

 store.dispatch( dispatch => { return api.fetchUsers() .then(users => dispatch(usersFetched(users)) // usersFetched is a function that returns a plain JavaScript object (Action) .catch(err => dispatch(usersFetchError(err)) // same with usersFetchError } )

Es importante tener en cuenta que el uso de thunks puede hacer que su código sea difícil de probar y hace que sea más difícil razonar a través del flujo de código.

redux-saga

Redux-saga admite acciones impuras a través de una característica de ES6 (ES2015) llamada generadores y una biblioteca de ayudantes funcionales/puros. Lo mejor de los generadores es que se pueden reanudar y pausar, y su contrato API los hace extremadamente fáciles de probar.

¡Veamos cómo podemos mejorar la legibilidad y la capacidad de prueba del método thunk anterior usando sagas!

Primero, montemos el middleware Redux-saga en nuestra tienda:

 import { createStore, applyMiddleware } from 'redux' import createSagaMiddleware from 'redux-saga' import rootReducer from './rootReducer' import rootSaga from './rootSaga' // create the saga middleware const sagaMiddleware = createSagaMiddleware() // mount the middleware to the store const store = createStore( rootReducer, applyMiddleware(sagaMiddleware), ) // run our saga! sagaMiddleware.run(rootSaga)

Tenga en cuenta que la función run(..) debe llamarse con la saga para que comience a ejecutarse.

Ahora vamos a crear nuestra saga:

 import { call, put, takeEvery } from 'redux-saga/effects' // these are saga effects we'll use export function *fetchUsers(action) { try { const users = yield call(api.fetchUsers) yield put(usersFetched(users)) } catch (err) { yield put(usersFetchFailed(err)) } } export default function *rootSaga() { yield takeEvery('USERS_FETCH', fetchUsers) }

Definimos dos funciones generadoras, una que obtiene la lista de usuarios y rootSaga . Tenga en cuenta que no llamamos a api.fetchUsers directamente, sino que lo generamos en un objeto de llamada. Esto se debe a que Redux-saga intercepta el objeto de llamada y ejecuta la función contenida dentro para crear un entorno puro (en lo que respecta a sus generadores).

rootSaga produce una sola llamada a una función llamada takeEvery, que toma cada acción enviada con un tipo de USERS_FETCH y llama a la saga fetchUsers con la acción que tomó. Como podemos ver, esto crea un modelo de efectos secundarios muy predecible para Redux, ¡lo que hace que sea fácil de probar!

Prueba de sagas

Veamos cómo los generadores hacen que nuestras sagas sean fáciles de probar. Usaremos mocha en esta parte para ejecutar nuestras pruebas unitarias y chai para afirmaciones.

Debido a que las sagas producen objetos JavaScript simples y se ejecutan dentro de un generador, ¡podemos probar fácilmente que realizan el comportamiento correcto sin simulacros en absoluto! Tenga en cuenta que call , take , put , etc. son simplemente objetos de JavaScript que son interceptados por el middleware Redux-saga.

 import { take, call } from 'redux-saga/effects' import { expect } from 'chai' import { rootSaga, fetchUsers } from '../rootSaga' describe('saga unit test', () => { it('should take every USERS_FETCH action', () => { const gen = rootSaga() // create our generator iterable expect(gen.next().value).to.be.eql(take('USERS_FETCH')) // assert the yield block does have the expected value expect(gen.next().done).to.be.equal(false) // assert that the generator loops infinitely }) it('should fetch the users if successful', () => { const gen = fetchUsers() expect(gen.next().value).to.be.eql(call(api.fetchUsers)) // expect that the call effect was yielded const users = [ user1, user2 ] // some mock response expect(gen.next(users).value).to.be.eql(put(usersFetched(users)) }) it('should fail if API fails', () => { const gen = fetchUsers() expect(gen.next().value).to.be.eql(call(api.fetchUsers)) // expect that the call effect was yielded const err = { message: 'authentication failed' } // some mock error expect(gen.throw(err).value).to.be.eql(put(usersFetchFailed(err)) }) })

Trabajando con reaccionar

Si bien Redux no está vinculado a ninguna biblioteca complementaria específica, funciona especialmente bien con React.js, ya que los componentes de React son funciones puras que toman un estado como entrada y producen un DOM virtual como salida.

React-Redux es una biblioteca de ayuda para React y Redux que elimina la mayor parte del arduo trabajo de conectar los dos. Para usar React-Redux de manera más efectiva, repasemos la noción de componentes de presentación y componentes de contenedor.

Los componentes de presentación describen cómo deberían verse las cosas visualmente, dependiendo únicamente de sus accesorios para representar; invocan devoluciones de llamada de accesorios para enviar acciones. Están escritos a mano, completamente puros y no están vinculados a sistemas de gestión estatales como Redux.

Los componentes del contenedor, por otro lado, describen cómo deberían funcionar las cosas, son conscientes de Redux, envían acciones de Redux directamente para realizar mutaciones y generalmente son generados por React-Redux. A menudo se combinan con un componente de presentación, proporcionando sus accesorios.

Componentes de presentación y componentes de contenedor en Redux.

Escribamos un componente de presentación y conéctelo a Redux a través de React-Redux:

 const HelloWorld = ({ count, onButtonClicked }) => ( <div> <span>Hello! You've clicked the button {count} times!</span> <button onClick={onButtonClicked}>Click me</button> </div> ) HelloWorld.propTypes = { count: PropTypes.number.isRequired, onButtonClicked: PropTypes.func.isRequired, }

Tenga en cuenta que este es un componente "tonto" que depende completamente de sus accesorios para funcionar. Esto es genial, porque hace que el componente React sea fácil de probar y componer. Veamos cómo conectar este componente a Redux ahora, pero primero veamos qué es un componente de orden superior.

Componentes de orden superior

React-Redux proporciona una función auxiliar llamada connect( .. ) que crea un componente de orden superior a partir de un componente React "tonto" que es consciente de Redux.

React enfatiza la extensibilidad y la reutilización a través de la composición, que es cuando envuelve componentes en otros componentes. Envolver estos componentes puede cambiar su comportamiento o agregar una nueva funcionalidad. Veamos cómo podemos crear un componente de orden superior a partir de nuestro componente de presentación que tenga en cuenta Redux: un componente de contenedor.

Así es como lo haces:

 import { connect } from 'react-redux' const mapStateToProps = state => { // state is the state of our store // return the props that we want to use for our component return { count: state.count, } } const mapDispatchToProps = dispatch => { // dispatch is our store dispatch function // return the props that we want to use for our component return { onButtonClicked: () => { dispatch({ type: 'BUTTON_CLICKED' }) }, } } // create our enhancer function const enhancer = connect(mapStateToProps, mapDispatchToProps) // wrap our "dumb" component with the enhancer const HelloWorldContainer = enhancer(HelloWorld) // and finally we export it export default HelloWorldContainer

Tenga en cuenta que definimos dos funciones, mapStateToProps y mapDispatchToProps .

mapStateToProps es una función pura de (estado: Objeto) que devuelve un objeto calculado a partir del estado Redux. Este objeto se fusionará con los accesorios pasados ​​al componente envuelto. Esto también se conoce como selector, ya que selecciona partes del estado de Redux para fusionarlas con los accesorios del componente.

mapDispatchToProps también es una función pura, pero una de (dispatch: (Action) => void) que devuelve un objeto calculado a partir de la función de despacho de Redux. Este objeto también se fusionará con los accesorios pasados ​​al componente envuelto.

Ahora, para usar nuestro componente contenedor, debemos usar el componente Provider en React-Redux para decirle al componente contenedor qué tienda usar:

 import { Provider } from 'react-redux' import { render } from 'react-dom' import store from './store' // where ever your Redux store resides import HelloWorld from './HelloWorld' render( ( <Provider store={store}> <HelloWorld /> </Provider> ), document.getElementById('container') )

El componente Provider propaga la tienda a cualquier componente secundario que se suscriba a la tienda Redux, manteniendo todo en un solo lugar y reduciendo los puntos de error o mutación.

Genere confianza en el código con Redux

Con este nuevo conocimiento de Redux, sus numerosas bibliotecas de soporte y su conexión de marco con React.js, puede limitar fácilmente la cantidad de mutaciones en su aplicación a través del control de estado. El control de estado sólido, a su vez, le permite moverse más rápido y crear una base de código sólida con más confianza.