Cómo manejar efectos secundarios en Angular usando NgRx Effects

¡Efectos secundarios! Son una de las tareas más comunes en nuestras aplicaciones. En Angular, al construir una aplicación, si no tenemos cuidado el componente termina con demasiadas responsabilidades: obtener los datos, procesarlos y renderizarlos en pantalla. La mayoría de las veces, cuando necesitamos consultar datos de una API, en lugar de colocar toda la lógica de las peticiones HTTP directamente en el componente, creamos servicios específicos para encapsularla. Sin embargo, nuestros componentes todavía necesitan inyectar esos servicios y suscribirse a ellos.
Cuando usamos NgRx, la idea principal es que los componentes se limiten a disparar acciones. Estas acciones hacen que el reducer realice los cambios necesarios en el estado, y luego leemos los datos actualizados a través de selectores en el componente.
Pero, ¿cómo manejamos los cambios que implican efectos secundarios? Por ejemplo, ¿cómo iniciamos una petición HTTP, obtenemos los datos y disparamos una acción con el resultado? ¿Quién es el responsable de consultar los datos, procesarlos y actualizar el estado?
Veamos un escenario práctico: necesito mostrar una lista de jugadores desde mi estado, y estos jugadores provienen de una API. Para iniciar este proceso, tenemos dos acciones: Players Load y Player Loaded Success.
export const HomePageActions = createActionGroup({
source: 'Home Page',
events: {
'Accept Terms': emptyProps(),
'Reject Terms': emptyProps(),
'Players Load': emptyProps(),
'Player Loaded Success': props<{ players: Array<any> }>(),
},
});Para mantener una separación clara de responsabilidades, creamos players.service.ts con la única tarea de consultar los datos de la API:
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { delay, map } from 'rxjs';
import { environment } from '../../environments/environment';
import { Player } from '../entities/player';
@Injectable({ providedIn: 'root' })
export class PlayersService {
private _http = inject(HttpClient);
public getPlayers() {
return this._http
.get<{ data: Array<Player> }>(`${environment.apiUrl}/players`, {
headers: {
Authorization: `${environment.token}`,
},
})
.pipe(
map((response) => response.data),
delay(5000),
);
}
}Ahora bien, no podemos cambiar el estado directamente dentro del reducer realizando tareas asíncronas, ya que un reducer debe ser una función pura: no puede ejecutar llamadas HTTP ni despachar acciones desde su interior. Por lo tanto, el único lugar que parece quedarnos disponible es usar el propio componente para despachar las acciones cuando reciba los datos.
Si abrimos home.component.ts, inyectamos PlayersService, y en el hook ngOnInit despachamos la acción HomePageActions.playersLoad() para activar el indicador de carga. Luego nos suscribimos a this._playersService.getPlayers(), y cuando llegan los datos, despachamos la acción playerLoadedSuccess con la respuesta.
El código se ve así:
export class HomeComponent implements OnInit {
private _store = inject(Store);
private _playersService = inject(PlayersService);
public $loading = this._store.selectSignal(selectLoading);
public $players = this._store.selectSignal(selectPlayers);
/**
otras propiedades omitidas para mantener la simplicidad.
**/
public ngOnInit(): void {
this._store.dispatch(HomePageActions.playersLoad());
this._playersService.getPlayers().subscribe(players => {
this._store.dispatch(HomePageActions.playerLoadedSuccess({
players
}))
})
}El código anterior funciona, pero ¿por qué home.component tiene que suscribirse al servicio y además encargarse de despachar la acción cuando llegan los datos? ¿Por qué necesita saber quién es el responsable de cargar la información? El componente de inicio solo debería ocuparse de disparar acciones y reaccionar a los cambios de estado.
Aquí es donde NgRx Effects resulta tan útil. Los effects escuchan acciones, ejecutan las operaciones asíncronas necesarias y despachan nuevas acciones con los resultados.
Los Effects en NgRx
¿Qué es un effect? En su forma clásica, es una clase decorada con @Injectable que tiene inyectado el servicio Actions. El servicio Actions nos permite escuchar cada acción despachada en la aplicación, justo después de que pase por el reducer.
@Injectable()
export class HomeEffects {
private _actions = inject(Actions);
}Declaramos una propiedad usando la función createEffect. Cualquier acción que retorne el flujo del effect se despacha automáticamente al Store. Filtramos las acciones usando el operador ofType, que recibe una o más acciones. Luego, ese flujo de acciones se aplana y se mapea a un nuevo observable mediante operadores de orden superior como concatMap, exhaustMap, switchMap o mergeMap.
loadPlayers = createEffect(() =>
this._actions.pipe(
ofType(HomePageActions.playersLoad)
));A partir de la versión 15.2 de NgRx también tenemos functional effects: en lugar de utilizar una clase, usamos directamente la función createEffect para declarar el effect como una constante.
export const loadPlayersEffect = createEffect(
(actions$ = inject(Actions)) => {
});¿Cómo funciona todo este flujo? El componente dispara la acción para cargar datos y el effect la intercepta. Luego, inyectamos el servicio para consultar la información en la API y disparamos una nueva acción con los datos obtenidos. Finalmente, el reducer escucha esa acción de éxito y actualiza el estado.
¿Parecen muchos pasos? Te muestro cómo refactorizar nuestro código paso a paso para usar Effects de forma limpia.
Migrando hacia Effects
Es momento de incorporar effects en nuestro proyecto. Tomaremos como punto de partida el repositorio de ejemplo de NgRx. Puedes clonarlo y cambiar a la rama feature/using-selectors:
git clone https://github.com/danywalls/start-with-ngrx.git
git switch feature/using-selectorsA continuación, instalamos el paquete @ngrx/effects desde la terminal:
npm i @ngrx/effectsAhora abre el proyecto en tu editor favorito y crea el archivo src/app/pages/home/state/home.effects.ts. Declaramos loadPlayersEffect usando la función createEffect, inyectamos Actions junto con PlayersService, e iniciamos el encadenamiento con pipe:
import { inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { PlayersService } from '../../../services/players.service';
export const loadPlayersEffect = createEffect(
(actions$ = inject(Actions), playersService = inject(PlayersService)) => {
return actions$.pipe()
)
);Usamos el operador ofType para filtrar únicamente la acción HomePageActions.playersLoad:
loadPlayers = createEffect(() =>
this._actions.pipe(
ofType(HomePageActions.playersLoad)
)
);Agregamos el operador concatMap para recibir el flujo de la acción, llamamos al método getPlayers() del servicio y usamos map para despachar HomePageActions.playerLoadedSuccess({ players }):
concatMap(() =>
this._playersService
.getPlayers()
.pipe(
map((players) => HomePageActions.playerLoadedSuccess({ players })),
),
),Después del map, gestionamos los errores mediante el operador catchError. Con la función of convertimos el fallo en la acción HomePageActions.playerLoadFailure para despachar el mensaje de error correspondiente:
catchError((error: { message: string }) =>
of(HomePageActions.playerLoadFailure({ message: error.message })),
),El código completo del effect queda así:
import { inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { PlayersService } from '../../../services/players.service';
import { HomePageActions } from './home.actions';
import { catchError, concatMap, map, of } from 'rxjs';
export const loadPlayersEffect = createEffect(
(actions$ = inject(Actions), playersService = inject(PlayersService)) => {
return actions$.pipe(
ofType(HomePageActions.playersLoad),
concatMap(() =>
playersService.getPlayers().pipe(
map((players) => HomePageActions.playerLoadedSuccess({ players })),
catchError((error: { message: string }) =>
of(HomePageActions.playerLoadFailure({ message: error.message })),
),
),
),
);
},
{ functional: true },
);Con el effect listo, solo falta registrarlo en app.config.ts. Importamos homeEffects y usamos la función provideEffects para registrarlo en los providers:
El archivo app.config.ts se ve de la siguiente manera:
import * as homeEffects from './pages/home/state/home.effects';
export const appConfig = {
providers: [
provideRouter(routes),
provideStore({
home: homeReducer,
}),
provideStoreDevtools({
name: 'nba-app',
maxAge: 30,
trace: true,
connectInZone: true,
}),
provideEffects(homeEffects), // registramos los effects
provideAnimationsAsync(),
provideHttpClient(withInterceptors([authorizationInterceptor])),
],
};Una vez registrado el effect, es hora de refactorizar el código de HomeComponent. Eliminamos la inyección de PlayersService, ya que no necesitamos suscribirnos al servicio directamente desde el componente.
El componente queda así de simple:
export class HomeComponent implements OnInit {
private _store = inject(Store);
public $loading = this._store.selectSignal(selectLoading);
public $players = this._store.selectSignal(selectPlayers);
public $acceptTerms = this._store.selectSignal(selectAcceptTerms);
public $allTasksDone = this._store.selectSignal(selectAllTaskDone);
public ngOnInit(): void {
this._store.dispatch(HomePageActions.playersLoad());
}
onChange() {
this._store.dispatch(HomePageActions.acceptTerms());
}
onRejectTerms() {
this._store.dispatch(HomePageActions.rejectTerms());
}
}¡Listo! Nuestra aplicación ahora utiliza effects, y nuestros componentes se mantienen limpios, organizados y libres de tareas asíncronas.
Resumen
Aprendimos cómo manejar efectos secundarios como peticiones HTTP y cómo liberar a los componentes de responsabilidades que no les corresponden. Al utilizar acciones, reducers y effects para gestionar tanto el estado como las llamadas asíncronas, logramos mover la lógica de obtención de datos fuera de la vista. De esta manera, los componentes solo despachan acciones y reaccionan a los cambios de estado, lo que se traduce en un código mucho más limpio, predecible y fácil de mantener.
Modern frontend development — Angular, React, TypeScript, accessibility, and performance.
Preguntas Frecuentes
¿Qué son los NgRx Effects y por qué los necesitamos para los efectos secundarios en Angular?
Los NgRx Effects son una capa dedicada dentro de la arquitectura de NgRx para gestionar efectos secundarios como peticiones HTTP, temporizadores o interacciones con el navegador. Escuchan acciones despachadas, ejecutan operaciones asíncronas como llamadas a una API y despachan nuevas acciones con los resultados, manteniendo los reducers y componentes limpios y enfocados en sus propias responsabilidades.
¿Cómo intercepta un NgRx Effect una acción para disparar una petición HTTP?
Un NgRx Effect utiliza el flujo de Actions junto con operadores como ofType para filtrar acciones específicas. Luego, mediante operadores como switchMap o concatMap, realiza la llamada HTTP a través de un servicio. Una vez que el Observable resuelve, el effect despacha una acción de éxito o de fallo con los datos de respuesta hacia el store.
¿Cuál es la diferencia entre switchMap, mergeMap y concatMap dentro de los NgRx Effects?
switchMap cancela cualquier petición en curso y comienza una nueva, ideal para búsquedas donde solo importa la respuesta más reciente. mergeMap ejecuta todas las peticiones de forma concurrente sin cancelar ninguna, adecuado para operaciones paralelas independientes. concatMap encola las peticiones y las procesa una tras otra en estricto orden, fundamental cuando la secuencia de operaciones afecta la consistencia de los datos.
Artículos Relacionados
Cómo depurar NgRx usando Redux DevTools en Angular
Aprende a depurar el estado de NgRx en Angular moderno con Redux DevTools: configura provideStoreDevtools, rastrea acciones, inspecciona cambios y usa time-travel debugging.
Cómo usar NgRx Selectors en Angular
En NgRx, cuando queremos obtener datos del store, la forma más rápida suele ser usar store.select, pero tiene riesgos de tipado. Aprende a usar Selectors para consultar el estado con tipado seguro y mejor rendimiento.
Cuándo y por qué usar REDUX NgRx en Angular
En un artículo anterior escribí sobre cómo manejar el estado en Angular usando servicios, pero cuando la aplicación crece y necesita gestionar un estado grande, se vuelve complejo de mantener. Te cuento cuándo y por qué conviene usar NgRx.
Angular y Domino: Una combinación potente para Server-Side Rendering.
Si estás usando Angular y necesitas acceder al DOM con SSR, usa el paquete domino. Domino ofrece soporte para la API del DOM y selectores CSS para querySelector(), querySelectorAll() y matches(). Instala el paquete domino desde tu terminal.
Temas en componentes de Angular: Guía para principiantes
Cuando construimos componentes, necesitamos que sean flexibles para usarlos en muchos lugares o contextos, adaptando su estructura y colores según el caso.
Compartir este artículo
Si esta guía te resultó útil, compártela con tu equipo o con otros desarrolladores.
Software real. Lecciones reales.
Comparto las lecciones que aprendí por las malas, para que puedas evitarlas o estar preparado cuando ocurran.
Únete a 13,800+ desarrolladores y lectores.
Cero spam. Cancela tu suscripción en cualquier momento.