Fundamentos de los Efectos en NgRx
Side Effects — Llamadas asíncronas, HttpClient y API
¿Qué son los Effects en NgRx?
En NgRx, los “effects” o “side effects” se utilizan para manejar operaciones asíncronas y tareas que implican efectos secundarios en tu aplicación, como llamadas API o interacciones con almacenamiento externo. Los efectos secundarios son importantes porque permiten mantener la lógica asíncrona y los efectos secundarios aislados del manejo de estado principal, el cual se centra en ser predecible y sincrónico.
Los efectos secundarios brindan una manera para que los componentes no interactúen directamente con los servicios, logrando de esta manera aislar los servicios de los componentes.
Las principales características de los side effects son:
- ✅ Aíslan los efectos secundarios de los componentes, logrando así unos componentes más “puros”.
- ✅ Son servicios prolongados que se suscriben al observable de cada acción registrada en una Store.
- ✅ Los efectos filtran las acciones basados en el tipo que les interesa mediante operadores.
- ✅ Pueden realizar una acción síncrona, asíncrona o retorna una nueva acción.
Instalación de @ngrx/effects
Este paquete es independiente de @ngrx/store que instalamos en los artículos anteriores, por lo que debemos instalarlo. Para ello podemos ejecutar el siguiente comando:
ng add @ngrx/effects@latest
Este comando lo que hará será actualizar nuestro package.json
con las dependencias necesarias, las instalará y actualizará el archivo de configuración de la aplicación para añadir la función provideEffects()
¿Cómo se estructuran los Efectos en NgRx?
El proceso de un efecto lo podemos dividir en las siguientes partes:
💉 Servicio de Acciones
En NgRx, el servicio Actions es un servicio especial que puedes inyectar en los efectos. Este servicio proporciona un flujo observable de todas las acciones que se despachan en la aplicación.
Lo interesante de este servicio es que emite las acciones después de que el estado más reciente haya sido reducido. Esto significa que cuando un efecto recibe una acción a través de este servicio, ya se ha actualizado el estado correspondiente a esa acción.
Por ejemplo, si tienes un efecto que necesita realizar una tarea después de que una acción específica se despache y el estado se actualice, puedes utilizar el servicio Actions para “escuchar” esa acción específica.
Imagina que tienes una acción de “cargar usuarios”. Una vez que esta acción se despacha y el estado se actualiza (por ejemplo, indicando que la carga está en progreso), el efecto que escucha esta acción puede comenzar a realizar una petición HTTP para obtener los usuarios. Una vez que obtiene la respuesta, el efecto puede despachar otra acción para actualizar el estado con los datos de los usuarios.
⌗ Metadata
Los efectos son definidos utilizando la función createEffect
. Esta función no solo crea un efecto (un flujo observable que reacciona a las acciones de la aplicación), sino que también adjunta información adicional, conocida como metadata, a este flujo.
La metadata es un conjunto de datos que acompaña al efecto y proporciona información adicional sobre cómo debe comportarse el efecto. Por ejemplo, la metadata puede indicar si las acciones emitidas por el efecto deben ser despachadas de vuelta al Store.
Uno de los roles clave de los efectos en NgRx es la posibilidad de despachar nuevas acciones al Store. Si un efecto produce una acción como resultado de su flujo observable, esta acción se envía de vuelta al Store. Por ejemplo, después de realizar una llamada API exitosa, un efecto puede despachar una acción con los datos obtenidos para actualizar el estado en el Store.
Imagina un efecto que escucha acciones de tipo “CARGAR_DATOS”. Cuando esta acción es captada por el efecto, se realiza una llamada API. Una vez que la respuesta es recibida, el efecto emite una nueva acción, como “DATOS_CARGADOS”, con los datos obtenidos. Esta acción emitida por el efecto es entonces despachada automáticamente al Store, gracias a la configuración establecida en la metadata del efecto.
🔀 Operador ofType
En NgRx, el operador ofType
es parte de la librería NgRx y se utiliza en combinación con flujos observables.
El propósito principal de
ofType
es determinar sobre qué acciones específicas un efecto debe actuar. En una aplicación que maneja múltiples tipos de acciones, no todos los efectos necesitan responder a cada acción.ofType
permite seleccionar solo aquellas acciones que son relevantes para un efecto particular.
Supongamos que tienes acciones como CARGAR_USUARIOS
, ACTUALIZAR_USUARIO
y ELIMINAR_USUARIO
. Si deseas crear un efecto que solo responda a la acción CARGAR_USUARIOS
, usarías ofType
de la siguiente manera: actions$.pipe(ofType('CARGAR_USUARIOS'))
. De esta manera, el efecto solo reaccionará y se ejecutará cuando se despache la acción CARGAR_USUARIOS
.
📝 Suscripción de efectos a la Store
Recordemos que los efectos en NgRx son servicios inyectables que permiten manejar operaciones secundarias o side effects, como llamadas a API, navegación, o acciones que no forman parte del flujo sincrónico de actualización del estado. Estas operaciones se realizan en respuesta a acciones despachadas desde el Store.
Los efectos en NgRx se suscriben al observable del Store para escuchar y reaccionar a acciones específicas.
💉 Inyección de Servicios en Efectos
Los efectos en NgRx utilizan servicios inyectados para interactuar con API externas y manejar flujos de datos. Estos servicios encapsulan la lógica de estas interacciones, permitiendo que los efectos se enfoquen en la reacción a acciones y en la orquestación de flujos de datos, manteniendo así una clara separación de responsabilidades y promoviendo la reutilización y mantenimiento del código.
¿Cómo se crean Effects?
Class-Based Effects
Para crear un efecto podemos hacerlo creando una clase @Injectable
e inyectar sus dependencias en el constructor (como haríamos en cualquier otra clase), en el constructor debemos inyectar al menos Actions
que nos provee del observable de acciones. Vamos a ver un ejemplo:
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { EMPTY } from 'rxjs';
import { map, exhaustMap, catchError } from 'rxjs/operators';
import { MoviesService } from './movies.service';
@Injectable()
export class MoviesEffects {
loadMovies$ = createEffect(() => this.actions$.pipe(
ofType('[Movies Page] Load Movies'),
exhaustMap(() => this.moviesService.getAll()
.pipe(
map(movies => ({ type: '[Movies API] Movies Loaded Success', payload: movies })),
catchError(() => EMPTY)
))
)
);
constructor(
private actions$: Actions,
private moviesService: MoviesService
) {}
}
- Usamos el decorador
@Injectable()
para que la clase pueda inyectarse en otros lugares. - Inyectamos el servicio
Actions
y cualquier otro servicio necesario en el constructor de la clase. - Utilizamos el método
createEffect
del paquete@ngrx/effects
para crear un nuevo efecto. - Utilizamos el operador
ofType
del paquete@ngrx/effects
para indicar sobre qué acción queremos que se asocie el efecto. - Usamos otros operadores RxJS como
mergeMap
,map
, ycatchError
para manejar el flujo de acciones y efectos secundarios (aquí tienes un artículo donde hablo de estos operadores).
Functional Effects
Para crear efectos funcionales también podemos utilizar la función createEffects
, pero en este caso no es necesario crear una clase @Injectable
. En este caso, para inyectar las dependencias utilizamos la función inject
de angular. Además, es importante destacar el último parámetro de la función createEffects
, en este parámetro debemos definir un objeto con, al menos, la variable functional
a true
, de esta manera se creará un efecto funcional, adicionalmente podemos definir la propiedad dispatch
a false
para definir que este efecto NO devuelve ningún estado. Vamos a ver un ejemplo:
import { inject } from '@angular/core';
import { catchError, exhaustMap, map, of, tap } from 'rxjs';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { ActorsService } from './actors.service';
import { ActorsPageActions } from './actors-page.actions';
import { ActorsApiActions } from './actors-api.actions';
export const loadActors = createEffect(
(actions$ = inject(Actions), actorsService = inject(ActorsService)) => {
return actions$.pipe(
ofType(ActorsPageActions.opened),
exhaustMap(() =>
actorsService.getAll().pipe(
map((actors) => ActorsApiActions.actorsLoadedSuccess({ actors })),
catchError((error: { message: string }) =>
of(ActorsApiActions.actorsLoadedFailure({ errorMsg: error.message }))
)
)
)
);
},
{ functional: true }
);
export const displayErrorAlert = createEffect(
() => {
return inject(Actions).pipe(
ofType(ActorsApiActions.actorsLoadedFailure),
tap(({ errorMsg }) => alert(errorMsg))
);
},
{ functional: true, dispatch: false }
);
- Utilizamos la función
createEffect
para definir el efecto. - Inyectamos las dependencias
Actions
yActorsService
usandoinject
. - Para filtrar la acción
opened
delActorsPageActions
utilizamos el operadorpipe
conofType
. - Cuando se recibe una acción de tipo
ActorsPageActions.opened
,exhaustMap
llama aactorsService.getAll()
. Si hay una llamada en curso iniciada por una acción previa, las nuevas llamadas serán ignoradas hasta que la actual se complete. Esto previene la sobreposición de múltiples efectos. - Dentro del
exhaustMap
, el servicioactorsService.getAll()
se llama para obtener todos los actores. Luego, se utilizapipe
para encadenar los operadoresmap
ycatchError
. - Cuando
actorsService.getAll()
retorna con éxito,map
toma la lista de actores (actors
) y los transforma en una acciónActorsApiActions.actorsLoadedSuccess
. Esta acción se despachará con la lista de actores como payload. - En caso de que
actorsService.getAll()
falle,catchError
captura el error. Utiliza la funciónof
para transformar el mensaje de error en una acciónActorsApiActions.actorsLoadedFailure
, que se despachará con el mensaje de error. - En el caso de efecto
displayErrorAlert
, al indicarse que no se despache, al lanzarse esta acción simplemente mostrará un mensaje de error, sin realizar ninguna modificación en el estado.
Este es artículo es parte de mi curso de Introducción a NgRx, donde abordamos temas como Store, Actions, Reducers, Selectors, Effects y Features. Si no te suena algún tema este artículo, ¡te recomiendo revisar mi curso!
Registrar Efecto
Para registrar los efectos, ya sean clases o funcionales, debemos invocar al método provideEffects
de @ngrx/effects
y como parámetros nuestros efectos:
import { bootstrapApplication } from '@angular/platform-browser';
import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { AppComponent } from './app.component';
import { MoviesEffects } from './effects/movies.effects';
import * as actorsEffects from './effects/actors.effects';
bootstrapApplication(AppComponent, {
providers: [
provideStore(),
provideEffects(MoviesEffects, actorsEffects),
],
});
Si trabajamos con features, lo registraríamos en nuestro archivo de configuración de rutas invocando al mismo método:
import { Route } from '@angular/router';
import { provideEffects } from '@ngrx/effects';
import { MoviesEffects } from './effects/movies.effects';
import * as actorsEffects from './effects/actors.effects';
export const routes: Route[] = [
{
path: 'movies',
providers: [
provideEffects(MoviesEffects, actorsEffects)
]
}
];
Implementando Efectos en nuestra aplicación
Vamos a recordar nuestra maravillosa aplicación 🎉Ng Party Planner🎉. Ya tenemos una aplicación completamente funcional, incluso si añadimos llamadas API de manera tradicional nos funcionaría perfectamente, pero como hemos visto en este artículo, existe una manera más alineada a la filosofía de NgRx.
La API que vamos a utilizar es https://randomuser.me/, concretamente el endpoint es:
https://randomuser.me/api/?inc=name,email,cell,id,picture&results=10
La implementación en el service sería:
Quizás lo más destacable sería la utilización de la clase HttpParams para añadir los parámetros inc y results y el mappeo del objeto que devuelve la API a la clase Guest, además, en algunas ocasiones la API nos devuelve un id vacío y esto lo controlamos con el operador ternario, en caso de no tener un id generamos uno con la librería crypto.randomUUID()
.
Recuerda instalar la librería
@ngrx/effects
Ahora vamos a crear dos nuevas acciones, Page Opened
que se lanzará al abrir la página de los Invitados, Guests Loaded Failure
que se lanzará al existir un error y Guests Loaded Success
que se lanzará cuando se obtengan los datos correctamente (simplemente renombraremos la acción que ya tenemos). Por convención agruparemos estas dos últimas acciones en el nuevo grupo GuestAPIActions
(¡recuerda actualizar también el reducer!)
export const GuestActions = createActionGroup({
source: 'Guests',
events: {
'Page Opened': emptyProps(),
// ...demás acciones
},
});
export const GuestAPIActions = createActionGroup({
source: 'Guests API',
events: {
'Guests Loaded Failure': props<{ msg: string }>(),
'Guests Loaded Success': props<{ guests: Array<Guest> }>(),
},
});
Ahora vamos a codificar los efectos, queremos que al entrar a la página de Invitados se carguen los datos mediante la API, este efecto se asociará a la acción Page Opened que invocará a nuestro service y con el resultado del método getGuests
invocará a la acción Guests Loaded Success
que se encargará de realizar el cambio de estado correspondiente.
También se debe controlar un posible error durante la invocación, en este caso se mostrará un mensaje de error mediante el MessageService
y Toast
de PrimeNG.
Actualizamos nuestro guest.component.ts
para que en el ngOnInit
se dispare la acción Page Opened
. ¡Mira qué código más limpio!
Por último, registramos nuestros efectos en app.config.ts
:
// ...demás dependencias
import * as guestsEffects from './core/store/guests/guest.effects';
export const appConfig: ApplicationConfig = {
providers: [
// ...demás providers
provideEffects(guestsEffects),
],
};
Et voilá!
Si inspeccionamos las acciones disparadas, vemos que cuando accedemos a la página se lanza la acción Page Opened y pasados unos milisegundos se lanza la acción Guests Loaded Success, que es la encargada de ejecutar al reducer con el cambio del estado:
¡Enhorabuena! Hemos aprendido los conceptos fundamentales de esta librería e incluso hemos desarrollado una aplicación totalmente funcional aplicando estos conceptos, pero… ¿Existe alguna manera más “correcta” de hacerlo? La respuesta es ¡Sí!, si implementamos features para agrupar este segmento de estado mejoraríamos la mantenibilidad e incluso el rendimiento de nuestra aplicación. ¡Acompáñame a verlo cómo!