NgRx Effects
The NgRx Store is intentionally pure: reducers must be synchronous, deterministic functions with no I/O. But real applications need to call APIs, read from local storage, and schedule timers — all of which are side effects. NgRx Effects is the library that isolates this asynchronous, impure work. An effect listens for dispatched actions, runs the side effect using RxJS, and then dispatches new actions back into the store with the result. This keeps your reducers clean and makes your async flows declarative and testable.
How effects fit the data flow
A typical NgRx cycle with effects looks like this: a component dispatches an action (for example loadUsers), the reducer may flip a loading flag, and an effect listening for that same action performs the HTTP request. When the request resolves, the effect dispatches a success or failure action, which the reducer then uses to store the data or the error.
Crucially, an effect is just an observable that emits actions. NgRx subscribes to it and automatically dispatches whatever actions it emits. You never call store.dispatch() inside an effect — you map to an action object and let NgRx do the dispatching.
The single most common NgRx bug is an “infinite loop” effect that listens for an action and emits that same action. Always ensure the action type you emit is different from the one you listen for.
Defining the actions
Effects react to actions, so start by defining a small action group with createActionGroup.
// users.actions.ts
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import { User } from './user.model';
export const UsersActions = createActionGroup({
source: 'Users',
events: {
'Load Users': emptyProps(),
'Load Users Success': props<{ users: User[] }>(),
'Load Users Failure': props<{ error: string }>(),
},
});
Writing an effect
Effects are created with the createEffect function and the inject-based Actions stream. In modern Angular you write them inside a plain injectable class registered with provideEffects. Use inject() to grab dependencies — no constructor boilerplate required.
// users.effects.ts
import { inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, map, of, switchMap } from 'rxjs';
import { UsersActions } from './users.actions';
import { UserService } from './user.service';
export const loadUsers$ = createEffect(
(actions$ = inject(Actions), service = inject(UserService)) => {
return actions$.pipe(
ofType(UsersActions.loadUsers),
switchMap(() =>
service.getUsers().pipe(
map((users) => UsersActions.loadUsersSuccess({ users })),
catchError((err) =>
of(UsersActions.loadUsersFailure({ error: err.message })),
),
),
),
);
},
{ functional: true },
);
The ofType operator filters the action stream down to the actions you care about. The inner observable — the HTTP call — is flattened with a higher-order mapping operator, and catchError returns a new observable so a failed request never kills the effect stream.
Choosing the right flattening operator
The operator you use to flatten the inner request determines how concurrent dispatches behave. Picking the wrong one causes race conditions, so choose deliberately.
| Operator | Behavior | Use for |
|---|---|---|
switchMap | Cancels the previous inner request when a new action arrives | Typeahead search, navigation-driven loads |
concatMap | Queues requests, runs them strictly in order | Writes that must not reorder (e.g. saves) |
mergeMap | Runs all inner requests in parallel | Independent fire-and-forget calls |
exhaustMap | Ignores new actions while one is in flight | Login buttons, preventing double-submits |
catchErrormust be placed on the inner observable (the HTTP call), not on the outeractions$pipe. If you put it on the outer pipe, the whole effect completes after the first error and stops listening forever.
Registering effects
With standalone bootstrapping, register the store and effects in app.config.ts. Pass the effect classes or functional effect maps to provideEffects.
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { provideHttpClient } from '@angular/common/http';
import { usersReducer } from './users.reducer';
import * as UsersEffects from './users.effects';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
provideStore({ users: usersReducer }),
provideEffects(UsersEffects),
],
};
For lazy-loaded feature routes, use provideState and provideEffects inside the route’s providers array so the slice and its effects load only when the route is activated.
Effects that don’t dispatch
Sometimes a side effect has no follow-up action — logging analytics or navigating, for instance. Set dispatch: false so NgRx does not try to dispatch the emitted value.
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { tap } from 'rxjs';
import { UsersActions } from './users.actions';
export const navigateAfterLoad$ = createEffect(
(actions$ = inject(Actions), router = inject(Router)) => {
return actions$.pipe(
ofType(UsersActions.loadUsersSuccess),
tap(() => router.navigate(['/users'])),
);
},
{ functional: true, dispatch: false },
);
Verifying behavior
Once wired up, dispatching loadUsers from a component triggers the full cycle. With the Redux DevTools open you can watch the action sequence as the effect resolves.
Output:
@ngrx/store/init
[Users] Load Users
[Users] Load Users Success { users: [ {id: 1, …}, {id: 2, …} ] }
Best practices
- Keep reducers pure — push every API call, timer, and storage access into effects.
- Match the flattening operator to the semantics:
switchMapfor cancellable reads,exhaustMapfor submits,concatMapfor ordered writes. - Always wrap the inner request with
catchErrorreturning a failure action so one error never tears down the effect. - Never emit the same action type the effect listens for; this prevents infinite loops.
- Use
dispatch: falsefor navigation, logging, and other non-store side effects. - Co-locate actions, reducer, and effects per feature, and register them with
provideState/provideEffectsin lazy routes. - Keep effects thin: one action in, one observable, one action out — move complex logic into services.