Si estás leyendo esto, probablemente eres desarrollador frontend, y usas o estás aprendiendo React. Dado el caso, seguro que has visto Redux nombrado muchas veces en ofertas de empleo, blogs de tecnología etc. Estamos hablando de una de las librerías más usadas y potentes en la gestión de estado en el frontend en la actualidad. Está disponible como Redux tal cual para React, tenemos Ngrx para Angular y Vuex o Pinia para Vue. Si no tienes conocimientos básicos de React y de conceptos como Provider, Hook y demás, esta guía no es aún para ti.
Introducción teórica.
Antes de entrar en materia práctica, tenemos que conocer una serie de conceptos cuyo entendimiento nos hará más sencilla la implementación. En primer lugar, debemos conocer que Redux en sí es una librería de Javascript basada en el patrón Flux.
El patrón Flux es un patrón de arquitectura del frontend desarrollado por Facebook para gestionar el flujo de los datos en las aplicaciones web, con la intención de hacerlo predecible y fácil de debuggear. Se busca crear un flujo unidireccional de datos siguiendo los componentes básicos de este patrón.
- Actions: Objetos que definen qué ocurre.
- Dispatcher: Distribuidores que envían las acciones a los stores.
- Store: Esquema de datos que contiene el estado de la aplicación y la lógica de negocio.
- Views: Componentes de la interfaz de usuario.
El flujo marca el siguiente recorrido: Action → Dispatcher → Store → View → Action
.
Muchas librerías como Zustand y demás tienen su origen en este patrón también, Redux es simplemente una implementación de este patrón con características propias. Por ejemplo, en Redux sólo existe una única store
, donde estarán, en forma de árbol, todos los datos del estado de nuestra aplicación. Este estado de la aplicación es un objeto inmutable, de sólo lectura, únicamente pueden cambiar sus propiedades (teoría básica de referencias de objetos de Javascript y Typescript) mediante la emisión de acciones.
Tres conceptos importantes propios de Redux (dos de Redux en sí y otro del kit de Redux Toolkit) son:
- Reducers: Son funciones que reciben el estado actual y una acción concreta lanzada, determinando así los cambios que este estado va a sufrir y devolviendo su nueva versión.
-
Slice (Redux toolkit): Representa una porción del estado en sí, uno de esos fragmentos del árbol, junto con los
reducers
y losactions
que lo manejan, todo agrupado en único fichero y entidad propia. -
Middleware: Función que se ejecuta entre el momento que se dispara un
action
y el momento en que llega alreducer
. Podríamos decir que es algo así como un interceptor. Su función principal es la gestión de efectos secundarios. Este es el concepto más importante para entender qué es Redux Saga.
Redux así tal cual es de por si una herramienta muy potente para la gestión del estado como hemos dicho, pero usando los middleware
adecuados, como Redux Saga, podemos llevar nuestra aplicación a otro nivel.
Redux Saga es un middleware
especializado que usa funciones generadoras (funciones que pueden pausarse y reanudarse) de Javascript para manejar los efectos secundarios de manera muy potente y declarativa. Ejemplos de uso son:
- Cancelar tareas en progreso.
- Ejecutar múltiples tareas en paralelo.
- Manejar las requests de nuestra aplicación.
También es necesario conocer que es yield
, algo así como un botón de pausa, que espera a que la sentencia a la que precede termine, haga algo. Algo similar al await
, que se usa en estas funciones generadoras.
Hasta aquí, la explicación teórica. Es bastante probable que hasta ahora, todo te suene un poco a chino si no estás habituado al uso de este tipo de librerías, o incluso aunque sepas los conceptos, toda la arquitectura del patrón Redux es un poco difícil de digerir hasta que no tienes bastante práctica con ello, la curva de aprendizaje de esta librería puede ser bastante elevada incluso para gente muy senior, así que vayamos paso a paso a la explicación más práctica.
Instalación.
Vamos a partir de la base de que tienes ya tu aplicación de React inicializada, tu entorno de node y npm bien preparado. Si no es el caso, vas a necesitar acudir a una guía de React y aprender conceptos más básicos antes de llegar a necesitar este post.
El comando con los paquetes que necesitaremos instalar son:
npm i react-redux redux-saga @reduxjs/toolkit
Con estos paquetes, tendremos la base de Redux en React con react-redux
, las herramientas de Redux Toolkit para facilitar la instalación, y el middleware Redux Saga.
Estructura de directorios recomendada.
En este punto, antes de empezar, tengo que aclarar, que la estructura de directorios, tanto para este patrón de arquitectura como para cualquier aplicación, es algo totalmente abierto a gusto del usuario, sin olvidar por supuesto que existen ciertos estándares y convenciones que facilitan la lectura y el entendimiento de esta estructura para cualquier proyecto. Vamos a pensar que partimos de la estructura básica de tener dentro del source de nuestro proyecto, diferentes directorios por features, components, hooks etc. Lo que yo (y el estándar para estas librerías que estamos viendo, el cual sigo fielmente porque me parece lo más claro) recomiendo, sería tener un directorio store
con la siguiente estructura, pensando que tenemos por ejemplo una feature Book para una sección de detalles de un libro:
Esto sería la estructura más básica que necesitaríamos en algún proyecto, un directorio sagas
, selectors
y slices
dentro de store
. Dentro de estos, podemos tener más directorios por cada propia feature por ejemplo, o directamente ficheros si es una aplicación pequeñita, como el ejemplo. Por supuesto, un archivo index
donde crearemos y configuraremos el store
de nuestra aplicación. Todo esto lo veremos paso a paso en los siguientes puntos.
Antes de pasar a ello, comentar que esta estructura es ampliable con más directorios, por ejemplo: actions
, para la creación de acciones que no modifiquen nuestro store
y no tengan cabida dentro de los slices
, acciones que sean únicamente interceptadas en las sagas
para cualquier función que se nos ocurra, o también, un directorio middleware
para hacer nuestras funciones interceptoras customizadas, pero esto es algo más avanzado que me gustaría ver en otro post en el futuro.
Configurando un slice.
Vamos a seguir con la idea de que tenemos una entidad Book para el resto de la guía. Vamos a necesitar un slice
de nuestro estado dedicado a esta entidad (y a cada una de las necesarias en la aplicación).
Para ello, creamos nuestro archivo correspondiente al slice
como vimos en el paso anterior. En primer lugar, será necesario definir el tipado correspondiente a este slice
. De inicios vamos a pensar que tendremos propiedades para saber el estado de loading, el estado de error, una lista de libros y un libro seleccionado. Y por supuesto, tenemos que definir un objeto de estado inicial en base a este tipado.
Con el tipado y el initialState
ya definido, es momento de crear el slice
. Al crear el slice
, hemos de tener en cuenta que estamos creando tanto las actions
como los reducers
de esta sección del estado de una. La definición del tipado del estado de cada slice
es un punto crítico en esta arquitectura, se busca algo simple y entendible, generar caos dentro de los slice
incrementarán muchísimo la dificultad de escalado y mantenimiento de nuestra aplicación en futuro.
Redux Toolkit nos proporciona la función de createSlice
, que recibirá un objeto con las propiedades para definir el nombre de esta sección del estado, (será el prefijo de nuestras acciones, nos ayudará en la depuración de estas, sabiendo exactamente de qué slice
provienen), el estado inicial, los reducers
, y los extra reducers
.
Definiremos la propiedad reducers
como un objeto cuyas claves serán nuestras actions
, y el valor, la función de los reducers
que se ejecutarán al lanzar cada action
. Esta función esperará dos parámetros, el estado, y la propia acción. Tiparemos esta acción con PayloadAction
también de Redux Toolkit, con tipo genérico de lo que queramos que tenga esa action
, definiendo así a su vez los parámetros que necesitará esta action
para ser lanzada, y a los que tendremos acceso en el reducer
.
La propiedad extraReducers
es un caso parecido, con ciertas peculiaridades: aquí no estamos definiendo acciones de este slice
, vamos a definir casos de acciones de otros slices
que podrán tener efecto en el estado de este. Sabiendo esto, se hace evidente una buena práctica más a la hora de definir los slices
, y es que, en el momento que tengamos alguna información que queramos guardar en el estado pero que no sean de la feature que estemos trabajando en ese momento, aunque sea relacionada, será mejor tenerla en otro slice
aparte, para no aumentar la complejidad de cada slice
innecesariamente y hacerlos propios de cada feature, pues con esas otras acciones de otros slices
, el nodo del estado que definimos aquí podrá verse también modificado.
Esta propiedad espera una función que recibe como parámetro el builder
del slice
, teniendo la propiedad de addCase
que nos permitirá definir reducers
con estas acciones importadas de otro slice
.
Teniendo el slice
definido, exportaremos tanto sus acciones como sus reducers
con las propiedades correspondientes.
Definiendo la store.
Y ahora, ¿qué hacemos con esto? Pues el siguiente paso y el crucial, es definir nuestra store
e inyectarla en nuestra aplicación, donde haremos uso de ella.
Para ello, haremos uso del archivo index
dentro del directorio store
que creamos antes. Todo va siguiendo la lógica de la teoría que hemos visto. El conjunto de estos reducers
de los slices
es lo que forma nuestra store
, ¿verdad? Pues tenemos que combinarlos.
Dentro de Redux Toolkit, contamos con dos funciones esenciales, combineReducers
y configureStore
. Con combineReducers
, conseguiremos un reducer
combinado de todos los definidos en cada slice
, y una vez lo tengamos, hacemos uso del configureStore
, es una función que nos devolverá nuestra store
ya lista, recibe un objeto con múltiples propiedades, pero nosotros usaremos las esenciales para definir los reducers
, los middleware
(de momento no haremos gran cosa con esto hasta que no veamos luego Redux Saga y escalemos nuestra aplicación), y el devTools
, porque sí, Redux tiene diferentes extensiones en los navegadores como herramientas de desarrollo que nos facilitarán la depuración de nuestra aplicación. Es recomendable habilitarla únicamente en entornos de desarrollo y nunca en producción, pues esto podría dejar al usuario acceder a datos que no queremos que vea. (Tengo que destacar en este punto que no buscamos jamás tener dentro del estado de Redux información como claves y demás, lo que me refiero con datos que no queremos que el usuario vea no se trata de datos críticos, sino información de nuestras apis o de la app que tenemos guardadas y simplemente no mostramos en la app, pero repito, no hay que almacenar claves ni información crítica en Redux).
A partir de esta store
, podemos inferir tipos como RootState
para tener acceso al tipado completo de toda la store
desde otros ficheros como los de los selectores, desde nos será esencial para tipar de forma segura.
Dentro del middleware
, sin entrar mucho en detalle, estamos configurando los middleware
por defecto de Redux, en este caso, el serializableCheck
, que es un middleware
que nos lanza advertencias si estamos almacenando datos no serializados como Set
o Map
, pues Redux recomienda almacenar solo objetos planos convertibles a JSON para facilitar la persistencia, la hidratación etc. En este caso, nosotros lo deshabilitamos. Este paso no es necesario ni mucho menos.
El fichero debe quedar algo así:
Para poder acceder a esta store
ya configurada en nuestra aplicación, es imprescindible añadir el Provider
de react-redux
para envolver nuestra aplicación, en único punto, ya sea en un componente específico para los Providers
de toda la aplicación o en el fichero main
. Este provider
espera como prop la store
que hemos definido, la importamos y listo. Toda la app debe estar envuelta para poder usar Redux.
Con esto, ya tenemos la store
inyectada y lista para usarse en nuestra aplicación. Pero, ¿y ahora cómo la usamos?
Devtools.
Antes de empezar a ver cómo la usaremos, quiero hacer este pequeño disclaimer para hablar de la herramienta de Redux Devtools, disponible en Chrome y otros navegadores como extensión.
No es necesario instalar esto para usar Redux en nuestra aplicación, pero es tremendamente útil para el depurado y observar qué ocurre en nuestra aplicación.
El panel de esta extensión es bastante simple:
En la línea temporal de las actions
, veremos en orden cronológico las acciones que se van lanzando a medida que hagamos interacción con nuestra aplicación. Tenemos un selector de lo que queremos ver en esa acción concreta (al hacer click en cada una de ellas), y la información a mostrar.
Al clicar en cada acción, podemos ver la propia acción, con el payload
que lleva.
Podemos ver el estado correspondiente en ese momento.
Y podemos ver la diferencia que ha hecho en el estado esa acción al lanzarse y ser interceptada por el reducer
.
Existen más opciones dentro de la extensión, podemos ver el estado de nuestra aplicación en formato gráfico con la opción correspondiente en la barra inferior. Podemos ver la información en JSON raw, pausar y resetear el estado, etc.
Pero con lo explicado, sabemos lo básico para una depuración más o menos óptima. Volvamos a cómo usar la store
y lanzar este flujo de acciones.
useDispatch y useSelectors.
React-redux nos pone a disposición dos hooks en los que recae principalmente la responsabilidad del manejo de Redux en nuestra aplicación.
Es bastante simple de entender, useDispatch
nos devuelve el dispatcher, función que lanza las acciones. useSelector
nos devuelve valores concreto con memoization del estado, pudiendo decidir nosotros a qué queremos acceder. Gracias a esta memoization, nuestro componente se re-renderizará cada vez que este valor cambie. Es importante definir bien a qué dato queremos acceder para evitar re-renders innecesarios y así no echar a perder el rendimiento de nuestra aplicación.
Viendo la definición que tenemos, vamos a ver de ejemplo muy básico (sin Saga aun ni nada, ultra básico para entender el comportamiento) un componente basiquisimo de la lista de libros y un div para el detalle del libro seleccionado, haciendo también uso de un hook custom con axios para obtener la lista de libro, y luego, explicaremos paso a paso como funciona.
El hook se ve algo así.
Y el componente sería:
Explico paso a paso lo que está ocurriendo aquí:
- Tenemos un hook que invoca una request con Axios a una api de libros. A partir de un
useEffect
y un par deuseState
, el hook realiza la request y gestiona el estado de carga con elisLoading
y el error, los cuales devuelve. La magia está en el caso exitoso de la request. Estamos haciendo uso deldispatch
, función que nos devuelve el hookuseDispatch
dereact-redux
. Estamos lanzando la acción desetBookListSuccess
con la información de la api, seteando de esta manera la lista de libros en nuestro estado, tal como definimos. - En el componente, hacemos uso de este hook para invocarlo. Pero el hook no devuelve la lista de libros. ¿Cómo accedemos a ella? Hacemos uso del hook
useSelector
, el cual recibe una función con el state (mal tipado por mi parte, aquí podríamos usar elRootState
que creamos antes), y accedemos al dato concreto que queremos de el nodo book (definido en el index, dentro delcombineReducers
). Lo usamos tanto para obtener la lista de libros como el libro seleccionado, pues tenemos una sección para mostrar más datos del libro que queremos. Hacemos uso deluseDispatch
para lanzar la acción desetSelectedBook
al hacer click en cada libro al clickar, o setearlo a null en el botón correspondiente para deshacer esta selección.
¿Fácil verdad?
Veamos algunas mejoras que podemos tener.
Factoría de selectores.
Si recordáis, al hablar sobre la estructura de directorios propuesta, teníamos una carpeta de selectores. La idea es crear una factoría de selectores por entidad para tener agrupados todas las lógicas de selección de datos de cualquier punto del nodo del estado por entidad/feature.
Redux Toolkit nos provee una función llamada createSelector
, que nos permite, como su nombre indica, crear selectores a partir del contexto del estado y de otros selectores en sí, optimizando su memoization y pudiendo añadir cualquier lógica adicional, por ejemplo de transformación.
Os expongo un ejemplo de factoría de selectores para lo que tenemos, con ejemplos, y a continuación explico en detalle.
En primer lugar, creamos una función como la que usaríamos en el hook useSelector
para obtener el nodo completo de book. En selectores básicos, con la función createSelector
, le damos como parámetros el contexto con rootState
, y accedemos al valor que queramos.
En el ejemplo de getSelectedBookAuthor
y getSelectedBookTitle
, tenemos el caso de usar otro selector como contexto en la función.
Y por último, en el caso de getSelectedBookIdAndAuthor
, caso con lógica de devolver un objeto determinado por esos parámetros, recibe varios selectores para poder formar su respuesta.
Para usar estos selectores, únicamente importas el que quieras en tu componente, y se lo pasas al hook useSelector
.
A mi parecer, bastante más elegante, y permite tener los selectores bien organizados y no repetir el acceso a ellos ni la lógica que se quiera seguir para procesar los datos del estado.
¿Y si llevamos el flujo a otro nivel?
Potenciando el flujo con Redux-Saga.
Ya hablamos en la introducción sobre Redux-Saga, y lo tenemos instalado. ¿Cómo podemos utilizarlo? Vamos primero a crear una saga, en su carpeta correspondiente, de nuevo, adjunto el ejemplo y luego explico.
Lo primero que vamos a hacer, es pasar la lógica de loading y error al estado, para gestionarlo todo con Redux y Saga, nos vamos a deshacer totalmente del hook.
Necesitamos crear una acción dentro del slice
de book para hacer la request, donde se activará el estado de loading. Ya tenemos una acción para el caso de éxito, y también creamos otra para el estado de error. (Las propiedades ya las teniamos definidas en el tipado).
Estamos ajustando los valores del loading y del error según el estado que corresponda de la petición, nada más. Nos acordamos de exportarlas y nada más que hacer en el slice
. Habría que setear el isLoading
a false también en la acción del caso de éxito.
En los selectores, añadimos dos más para el isLoading
, y el isError
.
Y ahora, a la saga, que sigue la siguiente nomenclatura.
Aquí es donde la cosa se ha puesto un poco más complicada de entender, ¿verdad? Os lo explico paso a paso.
En primer lugar, hacemos un tipado con los tipos de los efectos que vayamos a utilizar en cada saga. Los efectos de las sagas son funciones que se pueden realizar dentro de estas funciones generadoras para una u otra funcionalidad. Los explicaré en cada paso.
Tenemos las saga principal, donde usamos el efecto spawn
, que crea un proceso independiente para el watcher, o si tenemos varios, de esta forma que si uno muere, el resto no se ven afectados. ¿Y qué es un watcher? Un watcher, es un vigilante, una función generadora que está a la espera de que pase algo, en este caso, una action
en la aplicación. Tenemos definida con el efecto take
la función setBookListRequest
. Este watcher, cada vez que esta action
sea lanzada, ejecutará la función que tiene como segundo parámetro en el take
.
Se pueden tener varios take
en el mismo watcher, es recomendable un watcher por saga, evidentemente, ya que lo tenemos separado por feature. Existen alternativas al take
como takeLatest
que, si existe una función generadora ejecutandose por una acción, si esta acción se vuelve a lanzar durante la ejecución de esta función, será cancelada. Existen otros como el race
, takeEvery
y tal. Para profundizar en todos los efectos sería necesario otro post, así que vayamos a los básicos, y si te interesa, vas a necesitar seguro estos efectos y te tocará buscarlos, pero para un uso básico quizás no hagan falta.
Nos hemos quedado en que el watcher ha escuchado la acción setBookListRequest
, por lo tanto, ejecuta la función generadora asociada.
En esta función, hace uso del efecto call
, que espera recibir de primer parámetro, una promesa, en este caso, la request a axios (podemos hacer nuestras custom promises como servicios, e importarlos aquí, es lo recomendado), y opcionalmente, un segundo parámetro que sería el body por si la request se tratase de un post.
Una vez termine y la constante de bookList
tenga valor, se hace uso del efecto put
, que es básicamente un dispatcher, lanza acciones. En el caso exitoso, lanzará la acción de success cambiando el estado, añadiendo la lista de libro y cancelando el estado de loading, o en el caso de error, lo correspondiente.
Nada más, como digo, hay muchos más efectos como el all
, que permite hacer varias ejecuciones de otros efectos en paralelo, para varias requests a api dentro de la misma función en saga (porque si tenemos varias, y las ponemos una detrás de otra con el yield call
, se harán en cascada y no en paralelo), o el mismo put
. Muchas combinaciones posibles harían este post demasiado extenso, y estamos buscando una configuración inicial básica.
Tenemos ahora que modificar el index
de nuestra store
, para lanzar el middleware
saga, y crear una saga root que haga spawn
de las que nosotros hayamos creado.
La modificación es tal cual crear sagaMiddleware
con su función correspondiente de createSagaMiddleware
, hacer un concat
con el default middleware
de Redux para añadirla a nuestra arquitectura, y correr la saga root que hemos hecho haciendo spawn
de todas las que importemos.
Listo.
Por último, veamos la modificación correspondiente al componente.
En el componente, tenemos un useEffect
que lanzará la acción que el watcher de la saga escucha, este lanzará el flujo que hemos implementado y explicado, mientras, el componente mostrará el render del estado de loading, hasta que el flujo termine y tengamos la lista de libros o el error.
Todo esto, depurable y trackeable de forma muy cómoda en Redux Devtools.
Esto es todo lo que se necesita saber para una configuración básica de Redux + Redux Saga en una aplicación de React.
Recalco que, sobretodo Redux Saga, tiene mucho más contenido para elevar la complejidad de lo que necesitemos realizar en nuestras operaciones de negocio, es un mundo extenso.
Quiero terminar con una pequeña conclusión, y es que, esta arquitectura permite un escalado y un debuggeo sencillo para aplicaciones con un flujo de datos importante, que vaya a necesitar muchas requests, acciones lanzadas. Es perfecta para aplicaciones grandes, aunque también usables para aplicaciones más pequeñas. Uno de sus mayores puntos negativos es la cantidad de boilerplate que hay que generar para cada cosa que queramos desarrollar, este punto es de hecho, la excusa más usada por los detractores de esta arquitectura, pues genera mucho código, a lo que cachan complejidad y que se puede volver un caos, y es verdad, pero no tiene que pasar si lo mantienes bien, y no hay que desechar una librería con millones de descargo y tanto uso por el boilerplate, si así fuese, tendríamos que deshacernos entonces de frameworks completos como Angular. En la mayoría de los casos, el miedo a usar Redux y Redux Saga viene por desconocimiento, como comenté al principio, tiene una curva de aprendizaje un poco elevada, pero todo es ponerse. Si lo manejas bien, tendrás maestrías en, para mi, la librería más potente y organizada de gestión de estado que existe.
Si el post resulta interesante, quizás haga un post más avanzado sobre operaciones más complejas con Redux Saga y middlewares personalizados para Redux.
Si has leído hasta aquí, muchísimas gracias por tu tiempo y deja un comentario.
Un saludo.