NgRx

Featured image

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.

enter image description here

Qué significa esto y cómo funciona?

enter image description here

enter image description here

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

enter image description here

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!

enter image description here

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:

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:

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!