@ngrx/store: Redux State Management and Side Effects in Angular
🗃️ @ngrx/store — Redux-Style State Management for Angular
@ngrx/store is a Redux-inspired, RxJS-powered state management library for Angular. It gives your app a single, immutable store (one source of truth) that you change only through a strict, predictable flow:
Component → dispatches an Action → Reducer produces new State → Selectors read State → Component updates.
Side effects (API calls, logging, navigation) live outside this pure flow in @ngrx/effects.
🧠 Core Concepts
| Concept | Role |
|---|---|
| Store | The single, immutable state container for the whole app. |
| Action | A plain object describing "something happened" (type + optional payload). |
| Reducer | A pure function (state, action) => newState — the only way state changes. |
| Selector | A pure, memoized function to read/derive slices of state. |
| Effect | Listens for actions, performs side effects (HTTP, etc.), and dispatches new actions. |
The golden rule: the one-way data flow is unidirectional and predictable. State is never mutated directly.
📦 Installation
# Installs @ngrx/store, @ngrx/effects, and dev tools
ng add @ngrx/store
ng add @ngrx/effects
ng add @ngrx/store-devtools
Or manually:
npm install @ngrx/store @ngrx/effects @ngrx/store-devtools
1️⃣ Actions — Describing What Happened
Actions are events. Use createAction with a descriptive, categorized type.
// todo.actions.ts
import { createAction, props } from '@ngrx/store';
import { Todo } from './todo.model';
// [Source] Event — good naming makes DevTools readable
export const addTodo = createAction(
'[Todo List] Add Todo',
props<{ title: string }>()
);
export const toggleTodo = createAction(
'[Todo List] Toggle Todo',
props<{ id: number }>()
);
// Async flow: request → success → failure trio
export const loadTodos = createAction('[Todo Page] Load Todos');
export const loadTodosSuccess = createAction(
'[Todo API] Load Todos Success',
props<{ todos: Todo[] }>()
);
export const loadTodosFailure = createAction(
'[Todo API] Load Todos Failure',
props<{ error: string }>()
);
💡 Naming convention:
[Source] Event. The source is where it happened (a page, component, or API), the event is what happened. This makes the DevTools action log self-documenting.
2️⃣ Reducer — Pure State Transitions
The reducer is a pure function: no side effects, no mutation — always return a new state object.
// todo.reducer.ts
import { createReducer, on } from '@ngrx/store';
import * as TodoActions from './todo.actions';
import { Todo } from './todo.model';
export interface TodoState {
todos: Todo[];
loading: boolean;
error: string | null;
}
export const initialState: TodoState = {
todos: [],
loading: false,
error: null,
};
export const todoReducer = createReducer(
initialState,
on(TodoActions.addTodo, (state, { title }) => ({
...state,
todos: [...state.todos, { id: Date.now(), title, completed: false }],
})),
on(TodoActions.toggleTodo, (state, { id }) => ({
...state,
todos: state.todos.map((t) =>
t.id === id ? { ...t, completed: !t.completed } : t
),
})),
on(TodoActions.loadTodos, (state) => ({
...state,
loading: true,
error: null,
})),
on(TodoActions.loadTodosSuccess, (state, { todos }) => ({
...state,
todos,
loading: false,
})),
on(TodoActions.loadTodosFailure, (state, { error }) => ({
...state,
loading: false,
error,
}))
);
⚠️ Never do state.todos.push(...) or state.loading = true. Always spread (...state) and return a new object — NgRx relies on immutability for change detection and time-travel debugging.
3️⃣ Selectors — Reading & Deriving State
Selectors are pure and memoized — they only recompute when their input slice changes, which keeps reads fast.
// todo.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { TodoState } from './todo.reducer';
export const selectTodoState = createFeatureSelector<TodoState>('todos');
export const selectAllTodos = createSelector(
selectTodoState,
(state) => state.todos
);
export const selectLoading = createSelector(
selectTodoState,
(state) => state.loading
);
// Derived state — composes other selectors
export const selectRemainingCount = createSelector(
selectAllTodos,
(todos) => todos.filter((t) => !t.completed).length
);
4️⃣ Registering the Store
For a standalone app (app.config.ts):
import { ApplicationConfig } from '@angular/core';
import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { todoReducer } from './todo.reducer';
import { TodoEffects } from './todo.effects';
export const appConfig: ApplicationConfig = {
providers: [
provideStore({ todos: todoReducer }),
provideEffects(TodoEffects),
provideStoreDevtools({ maxAge: 25 }), // time-travel debugging
],
};
For a classic NgModule app, use StoreModule.forRoot({ todos: todoReducer }) and EffectsModule.forRoot([TodoEffects]).
5️⃣ Using the Store in a Component
Dispatch actions to change state; select signals/observables to read it.
import { Component, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import * as TodoActions from './todo.actions';
import * as TodoSelectors from './todo.selectors';
@Component({
selector: 'app-todos',
standalone: true,
template: `
<h2>Todos ({{ remaining() }} left)</h2>
@if (loading()) {
<p>Loading…</p>
}
<input #box (keyup.enter)="add(box)" placeholder="New todo" />
<button (click)="load()">Load from API</button>
<ul>
@for (todo of todos(); track todo.id) {
<li (click)="toggle(todo.id)">
{{ todo.completed ? '✅' : '⬜' }} {{ todo.title }}
</li>
}
</ul>
`,
})
export class TodosComponent {
private store = inject(Store);
// selectSignal gives a signal; use store.select(...) for an Observable + async pipe
todos = this.store.selectSignal(TodoSelectors.selectAllTodos);
loading = this.store.selectSignal(TodoSelectors.selectLoading);
remaining = this.store.selectSignal(TodoSelectors.selectRemainingCount);
load() {
this.store.dispatch(TodoActions.loadTodos());
}
add(box: HTMLInputElement) {
if (box.value.trim()) {
this.store.dispatch(TodoActions.addTodo({ title: box.value.trim() }));
box.value = '';
}
}
toggle(id: number) {
this.store.dispatch(TodoActions.toggleTodo({ id }));
}
}
⚡ Handling Side Effects with @ngrx/effects
Reducers must stay pure, so anything asynchronous or impure — HTTP calls, logging, navigation, localStorage, toasts — belongs in an Effect.
An effect is an Observable pipeline that:
- Listens for a specific action via
ofType. - Runs a side effect (e.g. an HTTP request).
- Maps the result to a new action (success or failure) that the reducer handles.
API Call Effect
// todo.effects.ts
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { of } from 'rxjs';
import { map, exhaustMap, catchError } from 'rxjs/operators';
import { TodoService } from './todo.service';
import * as TodoActions from './todo.actions';
@Injectable()
export class TodoEffects {
private actions$ = inject(Actions);
private todoService = inject(TodoService);
loadTodos$ = createEffect(() =>
this.actions$.pipe(
ofType(TodoActions.loadTodos), // 1. listen
exhaustMap(() => // 2. call API
this.todoService.getAll().pipe(
map((todos) => TodoActions.loadTodosSuccess({ todos })), // 3a. success action
catchError((err) =>
of(TodoActions.loadTodosFailure({ error: err.message })) // 3b. failure action
)
)
)
)
);
}
Choosing the Right Flattening Operator
The operator controls how concurrent actions are handled — a critical correctness decision:
| Operator | Behavior | Use for |
|---|---|---|
switchMap | Cancels the previous request | Typeahead / search — only latest matters |
concatMap | Queues requests in order | Writes where order matters (e.g. sequential saves) |
exhaustMap | Ignores new requests while one runs | Login / submit — prevent double-clicks |
mergeMap | Runs all in parallel | Independent parallel requests (use carefully) |
⚠️ Always put
catchErroron the INNER Observable (inside the flattening operator). If it's on the outeractions$stream, one error kills the effect permanently and it stops listening for future actions.
Non-Dispatching Effects (Logging, Navigation, Storage)
Some effects don't produce a new action. Use { dispatch: false }.
import { tap } from 'rxjs/operators';
import { Router } from '@angular/router';
@Injectable()
export class TodoEffects {
private actions$ = inject(Actions);
private router = inject(Router);
// 📝 Logging effect — no action dispatched
logActions$ = createEffect(
() =>
this.actions$.pipe(
tap((action) => console.log('[Action]', action.type))
),
{ dispatch: false }
);
// 💾 Persist to localStorage after a successful load
persist$ = createEffect(
() =>
this.actions$.pipe(
ofType(TodoActions.loadTodosSuccess),
tap(({ todos }) =>
localStorage.setItem('todos', JSON.stringify(todos))
)
),
{ dispatch: false }
);
// 🧭 Navigate after an action
redirectAfterAdd$ = createEffect(
() =>
this.actions$.pipe(
ofType(TodoActions.addTodo),
tap(() => this.router.navigate(['/todos']))
),
{ dispatch: false }
);
}
Effects That Read State — concatLatestFrom
When an effect needs current state (e.g. avoid refetching if data already exists), combine the action with a selector:
import { concatLatestFrom } from '@ngrx/operators';
import { Store } from '@ngrx/store';
import { filter } from 'rxjs/operators';
loadOnce$ = createEffect(() =>
this.actions$.pipe(
ofType(TodoActions.loadTodos),
concatLatestFrom(() => this.store.select(TodoSelectors.selectAllTodos)),
filter(([_action, todos]) => todos.length === 0), // skip if already loaded
exhaustMap(() =>
this.todoService.getAll().pipe(
map((todos) => TodoActions.loadTodosSuccess({ todos })),
catchError((err) => of(TodoActions.loadTodosFailure({ error: err.message })))
)
)
)
);
🧭 The Full Async Flow, End to End
✅ Best Practices
- Keep reducers pure. No HTTP, no
Date.now()side effects beyond simple value creation, no mutation — always spread. - One source of truth. Don't duplicate store state into component fields; read via selectors.
- Use the action trio (
load/loadSuccess/loadFailure) for every async operation. - Put
catchErrorinside the inner Observable so effects keep running after a failure. - Pick the right flattening operator (
exhaustMapfor submits,switchMapfor search,concatMapfor ordered writes). - Use
{ dispatch: false }for logging, navigation, and persistence effects. - Memoize with selectors instead of computing derived data in components.
- Consider
@ngrx/entityto manage collections (CRUD) with less boilerplate.
🆚 When to Use @ngrx/store vs @ngrx/signals
@ngrx/store— large apps needing strict, auditable, unidirectional flow; time-travel debugging via Redux DevTools; a shared, well-understood Redux pattern across a big team.@ngrx/signals— most new apps wanting far less boilerplate, signal-native reads, and no manual subscription management.
See the companion guide on @ngrx/signals for the lighter-weight, signal-based alternative.
🎯 Summary
@ngrx/store brings the Redux pattern to Angular: a single immutable store, pure reducers, memoized selectors, and a strict unidirectional flow that makes state changes predictable and debuggable. Side effects stay out of reducers and live in @ngrx/effects, where Observable pipelines listen for actions, perform async work (HTTP, logging, navigation, persistence), and dispatch result actions back into the store. Combined with the DevTools, this gives you traceable, time-travelable state management for complex Angular applications. 🚀