11 min to read
NgRx
NgRx - States reactivos en Angular
Buenas! como andan? En este post quiero compartir algo que me había quedado en el tintero, sobre los conceptos, experiencias, aprendizajes, ventajas/desventajas de implementar states reactivos en Angular con NgRx
Qué es?
En pocas palabras, es un manejador de estado que usa rxjs para Angular, aplicando el patrón de arquitectura de datos -> Redux y rxjs.
Qué significa esto y cómo funciona?
Vamos por partessss:
Store
El Store para nosotros será la fuente de la verdad (single source of truth). Y refleja la condición actual de nuestra app.
Actions
Las Actions son eventos únicos que ocurren en nuestra app, interacciones del usuario, network requests, etc.
Reducers
Los Reducers son funciones puras, responsables de gestionar las transiciones, es decir, son quienes reaccionarán a las acciones despachadas, tomando el state actual y el último para generar un nuevo state con dicho cambio. (Sin mutar).
Selectors
Los Selectors son funciones puras para recuperar porciones del state del Store. Gracias a ellos, nuestra app puede escuchar los cambios en los states.
Effects
Los Effects manejan los efectos secundarios de cada acción. Es decir, para comunicarnos con servicios y apis externas cuando una acción específica es despachada, o también para despachar otra acción que actualiza otra parte del State.
Ejemplo funcional-conceptual
Entonces podemos decir que desde un botón de nuestro componente, necesitamos realizar una acción, por ejemplo, guardar. Para este caso, nuestro componente únicamente va a despachar una acción (guardar). Esta acción podría tener un efecto secundario (guardar esos datos en una api). Por lo que, nuestro reducer, va a reaccionar a esa acción guardando esos datos en el Store y ejecutando ese efecto secundario (llamando a la api con los datos a guardar).
En el caso de que este u otro componente, necesitara conocer alguna porción de esos datos, hará uso de los selectores que hayamos armados para tal fin.
De esta manera, podemos ver claramente que el componente únicamente puede despachar una acción o hacer uso de un selector. Nada más. Todo el resto del flujo (unidireccional) no pasa por el componente, queda totalmente aislado de esa lógica. El componente jamás podrá modificar, de ninguna manera, el Store, y es por este motivo que estamos aplicando la arquitectura de datos Redux.
Ejercicio
Habiendo previamente instalado nuestra lib “@ngrx/store”: “x.x.x”.
Supongamos una lista de películas, donde podemos ir agregando, borrando y toggleando (activa/inactiva).
Generamos las acciones posibles en un movie.actions.ts:
import { createAction, props } from "@ngrx/store";
export const Add = createAction(
"[MovieComponent] Add",
props<{ text: string }>()
);
export const Remove = createAction(
"[MovieComponent] Remove",
props<{ id: string }>()
);
export const Toggle = createAction(
"[MovieComponent] Toggle",
props<{ id: string }>()
);
Creamos los reducers, las funciones puras que van a reaccionar a esas acciones que armamos recién, movie.reducers.ts:
import { createReducer, on } from "@ngrx/store";
import { Movie } from "./interfaces"; // interface a tu gusto
import { Add, Remove, Toggle } from "./actions";
import * as uuid from "uuid";
const initialState: Array<Movie> = [];
export const movieReducer = createReducer(
initialState,
on(Add, (state, action) => [
...state,
{ id: uuid.v4(), text: action.text, movie: true },
]),
on(Remove, (state, action) => state.filter((i) => i.id !== action.id)),
on(Toggle, (state, action) =>
state.map((i) => (i.id === action.id ? { ...i, movie: !i.movie } : i))
)
);
Configuramos nuestro app.module
import { StoreModule } from '@ngrx/store';
import { movieReducer } from './movies/reducer';
...
StoreModule.forRoot({
movies: movieReducer
})
Y ahora sí, veamos como desde nuestro componente podemos recuperar esas pelis (selector) y cómo despachamos las acciones, movie.component.ts
import { Component } from "@angular/core";
import { Store } from "@ngrx/store";
import { Movie } from "./movies/interfaces";
import { Add, Remove, Toggle } from "./movies/actions";
import { Observable } from "rxjs";
@Component({
selector: "movie-app",
templateUrl: "./movie.component.html",
styleUrls: ["./movie.component.css"],
})
export class MovieComponent {
newMovieText: string = "";
movies$: Observable<Movie[]> = this.store.select((state) => state.movies);
constructor(private store: Store<{ movies: Movie[] }>) {}
addMovie() {
this.store.dispatch(Add({ text: this.newMovieText || "Untitled movie" }));
this.newMovieText = "";
}
removeMovie(id) {
this.store.dispatch(Remove({ id }));
}
toggleMovie(id) {
this.store.dispatch(Toggle({ id }));
}
}
En este ejemplo no agregamos efectos secundarios… pero si nos ponemos un poco picantes podríamos ser el primer perrito
Para esto, podríamos generar un moviesService para recuperar pelis de alguna api random (a gusto):
@Injectable({
providedIn: "root",
})
export class MoviesService {
constructor(private http: HttpClient) {}
getAll() {
return this.http.get("/movies");
}
}
Registramos el effect en app.module.ts:
EffectsModule.forRoot([MovieEffects]);
y ahora sí, nuestro movie.effects.ts para recuperarlas:
import { Injectable } from "@angular/core";
import { Actions, createEffect, ofType } from "@ngrx/effects";
import { EMPTY } from "rxjs";
import { map, mergeMap, catchError } from "rxjs/operators";
import { MoviesService } from "./movies.service";
@Injectable()
export class MovieEffects {
loadMovies$ = createEffect(() =>
this.actions$.pipe(
ofType("[Movies Page] Load Movies"),
mergeMap(() =>
this.moviesService
.getAll()
.pipe(
map((movies) => ({
type: "[Movies API] Movies Loaded Success",
payload: movies,
})),
catchError(() => EMPTY)
)
)
)
);
constructor(
private actions$: Actions,
private moviesService: MoviesService
) {}
}
De esta manera, nuestro componente sólo despacha el load:
this.store.dispatch({ type: "[Movies Page] Load Movies" });
//ó bien, creamos el action correspondiente y despachamos:
//this.store.dispatch(loadMovies());
Bonus!
Extensión de Chrome que vas a necesitar para debuggear: Redux DevTools
Conclusión
Lo más importante es tener siempre presente los tres principios fundamentales:
-
Una sola fuente de la verdad:
El estado de la app es almacenado en un único store. Esto facilita crear aplicaciones universales, nos hace más fácil el depurar y testear la aplicación.
-
El estado es solo lectura:
La única forma de modificar el estado de nuestra app es a través de una acción. Ya que todas las modificaciones están centralizadas y ocurren en un orden estricto, nos asegura consistencia y fiabilidad en la modificación de los estados.
-
Los cambios se hacen mediante funciones puras:
Para especificar cómo el estado es transformado por las acciones, se usan funciones puras. Estas toman el estado anterior y la acción y, a partir de estos, retornan un estado nuevo.
¿Por qué función pura? Porque pasándole el mismo param, debería retornar siempre el mismo resultado.
Implementar este patrón, sí o no?
Cuando hacemos una comparación entre usar o no este patrón, lo que sucede cuando no lo implementamos y lo hacemos de manera convencional, subscribiendonos a un servicio, y delegando el resto de tareas de lo que recibe el componente en cuestión, queda claro que el componente tiene múltiples responsabilidades como:
-
Gestionar el estado de las películas.
-
Usar un servicio para realizar un efecto secundario, llegando a una API externa para obtener las películas.
-
Cambiar el estado de las películas dentro del componente.
Es por esto que podemos ver que los effects, disminuyen la responsabilidad de nuestros componentes. En una app más grande, esto se vuelve más importante porque nuestros componentes empiezan a crecer en cantidad, tenemos muchas fuentes de datos, con muchos servicios requeridos para obtener esas piezas de datos, y servicios que incluso dependen de otros servicios.
Por lo tanto y siguiendo el ejercicio anterior, el componente ya no se encarga de cómo se obtienen y cargan las películas. Sólo es responsable de declarar su intención de cargar películas y de usar selectores para acceder a los datos de la lista de películas. Esto permite que el componente sea más fácil de probar y menos responsable de los datos que necesita.
Cabe destacar, que el effect que generamos en el ejercicio loadMovies$
, está escuchando todas las acciones enviadas a través del Actions stream, pero sólo está interesado en el evento [Movies Page] Load Movies
, es por eso que usamos el operador ofType. El flujo de acciones es entonces aplanado y mapeado en un nuevo observable usando el operador mergeMap
.
El método MoviesService#getAll()
retorna un observable que asigna las películas a una nueva acción en caso de éxito, y actualmente devuelve un observable vacío si se produce un error. La acción es enviada al [Store]
donde puede ser manejada por los reductores cuando se necesita un cambio de estado. Si quisiéramos además, agregar a este caso el manejo de errores, lo podemos ver acá!
En mi opinión, si estás desarrollando una app mediana/grande y compleja, vale la pena (incluso el boilerplate al principio), invertir y dedicarle tiempo y esfuerzo a NgRx, ya que en un corto/mediano plazo, se empiezan a notar sus frutos, facilitando la depuración, escalabilidad y mantención de la app, así como también la comprensión, transparencia y escalabilidad para trabajar incluso con nuevos desarrolladores que ingresan a los equipos.
Happy coding!