Skip to main content

@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

ConceptRole
StoreThe single, immutable state container for the whole app.
ActionA plain object describing "something happened" (type + optional payload).
ReducerA pure function (state, action) => newState — the only way state changes.
SelectorA pure, memoized function to read/derive slices of state.
EffectListens 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:

  1. Listens for a specific action via ofType.
  2. Runs a side effect (e.g. an HTTP request).
  3. 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:

OperatorBehaviorUse for
switchMapCancels the previous requestTypeahead / search — only latest matters
concatMapQueues requests in orderWrites where order matters (e.g. sequential saves)
exhaustMapIgnores new requests while one runsLogin / submit — prevent double-clicks
mergeMapRuns all in parallelIndependent parallel requests (use carefully)

⚠️ Always put catchError on the INNER Observable (inside the flattening operator). If it's on the outer actions$ 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 catchError inside the inner Observable so effects keep running after a failure.
  • Pick the right flattening operator (exhaustMap for submits, switchMap for search, concatMap for ordered writes).
  • Use { dispatch: false } for logging, navigation, and persistence effects.
  • Memoize with selectors instead of computing derived data in components.
  • Consider @ngrx/entity to 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. 🚀