Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 20: State Management with NgRx

NgRx is a state management library for Angular based on Redux principles: a single source of truth (the store), immutable state updates, and unidirectional data flow. It’s widely used in enterprise Angular applications.


20.1 NgRx Core Concepts

  1. Store – a single, centralized state tree for your app.
  2. Actions – plain objects that describe what happened.
  3. Reducers – pure functions that define how state changes.
  4. Selectors – functions to read pieces of state.
  5. Effects – handle side effects like HTTP calls.

20.2 Setting Up NgRx

Install the NgRx packages:

ng add @ngrx/store
ng add @ngrx/effects
ng add @ngrx/store-devtools

This sets up the store, devtools, and effects integration.


20.3 Building a Todo App (Local State)

We’ll build a Todo app with two components:

  • TodoFormComponent – adds new todos.
  • TodoListComponent – displays todos.

20.3.1 Define State and Model

todo.model.ts

export interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

20.3.2 Define Actions

todo.actions.ts

import { createAction, props } from '@ngrx/store';
import { Todo } from './todo.model';

export const addTodo = createAction(
  '[Todo] Add Todo',
  props<{ title: string }>()
);

export const toggleTodo = createAction(
  '[Todo] Toggle Todo',
  props<{ id: number }>()
);

export const deleteTodo = createAction(
  '[Todo] Delete Todo',
  props<{ id: number }>()
);

20.3.3 Reducer

todo.reducer.ts

import { createReducer, on } from '@ngrx/store';
import { addTodo, toggleTodo, deleteTodo } from './todo.actions';
import { Todo } from './todo.model';

export const initialState: Todo[] = [];

let nextId = 1;

export const todoReducer = createReducer(
  initialState,
  on(addTodo, (state, { title }) => [
    ...state,
    { id: nextId++, title, completed: false }
  ]),
  on(toggleTodo, (state, { id }) =>
    state.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    )
  ),
  on(deleteTodo, (state, { id }) =>
    state.filter(todo => todo.id !== id)
  )
);

20.3.4 Selectors

todo.selectors.ts

import { createSelector, createFeatureSelector } from '@ngrx/store';
import { Todo } from './todo.model';

export const selectTodos = createFeatureSelector<Todo[]>('todos');

export const selectCompletedTodos = createSelector(
  selectTodos,
  todos => todos.filter(t => t.completed)
);

20.3.5 Register the Store

main.ts

import { bootstrapApplication } from '@angular/platform-browser';
import { provideStore } from '@ngrx/store';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { AppComponent } from './app/app.component';
import { todoReducer } from './app/todos/todo.reducer';

bootstrapApplication(AppComponent, {
  providers: [
    provideStore({ todos: todoReducer }),
    provideStoreDevtools()
  ]
});

20.3.6 Components

TodoFormComponent

import { Component } from '@angular/core';
import { Store } from '@ngrx/store';
import { addTodo } from './todo.actions';

@Component({
  selector: 'app-todo-form',
  standalone: true,
  template: `
    <form (ngSubmit)="add()">
      <input [(ngModel)]="title" name="title" required>
      <button type="submit">Add</button>
    </form>
  `,
  imports: []
})
export class TodoFormComponent {
  title = '';

  constructor(private store: Store) {}

  add() {
    if (this.title.trim()) {
      this.store.dispatch(addTodo({ title: this.title }));
      this.title = '';
    }
  }
}

TodoListComponent

import { Component } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { Todo } from './todo.model';
import { selectTodos } from './todo.selectors';
import { toggleTodo, deleteTodo } from './todo.actions';
import { AsyncPipe, NgFor } from '@angular/common';

@Component({
  selector: 'app-todo-list',
  standalone: true,
  imports: [AsyncPipe, NgFor],
  template: `
    <ul>
      <li *ngFor="let todo of todos$ | async">
        <input type="checkbox"
          [checked]="todo.completed"
          (change)="toggle(todo.id)" />
        <span [style.text-decoration]="todo.completed ? 'line-through' : 'none'">
          {{ todo.title }}
        </span>
        <button (click)="remove(todo.id)">X</button>
      </li>
    </ul>
  `
})
export class TodoListComponent {
  todos$: Observable<Todo[]> = this.store.select(selectTodos);

  constructor(private store: Store) {}

  toggle(id: number) {
    this.store.dispatch(toggleTodo({ id }));
  }

  remove(id: number) {
    this.store.dispatch(deleteTodo({ id }));
  }
}

AppComponent

import { Component } from '@angular/core';
import { TodoFormComponent } from './todos/todo-form.component';
import { TodoListComponent } from './todos/todo-list.component';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [TodoFormComponent, TodoListComponent],
  template: `
    <h1>Todo App (NgRx)</h1>
    <app-todo-form></app-todo-form>
    <app-todo-list></app-todo-list>
  `
})
export class AppComponent {}

20.4 Adding REST API with Effects

Now let’s persist todos via a REST API (e.g., https://jsonplaceholder.typicode.com).


20.4.1 Update Actions

todo.actions.ts

// load from API
export const loadTodos = createAction('[Todo] Load Todos');
export const loadTodosSuccess = createAction(
  '[Todo] Load Todos Success',
  props<{ todos: Todo[] }>()
);
export const loadTodosFailure = createAction(
  '[Todo] Load Todos Failure',
  props<{ error: any }>()
);

20.4.2 Effects

todo.effects.ts

import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { HttpClient } from '@angular/common/http';
import { loadTodos, loadTodosSuccess, loadTodosFailure } from './todo.actions';
import { catchError, map, mergeMap, of } from 'rxjs';

@Injectable()
export class TodoEffects {
  constructor(private actions$: Actions, private http: HttpClient) {}

  loadTodos$ = createEffect(() =>
    this.actions$.pipe(
      ofType(loadTodos),
      mergeMap(() =>
        this.http.get<any[]>('https://jsonplaceholder.typicode.com/todos?_limit=5')
          .pipe(
            map(todos => loadTodosSuccess({ todos })),
            catchError(error => of(loadTodosFailure({ error })))
          )
      )
    )
  );
}

20.4.3 Register Effects

main.ts

import { provideEffects } from '@ngrx/effects';
import { TodoEffects } from './app/todos/todo.effects';
import { provideHttpClient } from '@angular/common/http';

bootstrapApplication(AppComponent, {
  providers: [
    provideStore({ todos: todoReducer }),
    provideEffects([TodoEffects]),
    provideHttpClient(),
    provideStoreDevtools()
  ]
});

20.4.4 Update Reducer

Handle API actions:

import { loadTodosSuccess } from './todo.actions';

export const todoReducer = createReducer(
  initialState,
  // ...
  on(loadTodosSuccess, (state, { todos }) => [...todos])
);

20.4.5 Trigger API Call in Component

@Component({
  // ...
})
export class TodoListComponent {
  todos$: Observable<Todo[]> = this.store.select(selectTodos);

  constructor(private store: Store) {
    this.store.dispatch(loadTodos()); // load on init
  }

  // toggle/remove unchanged
}

20.5 Summary

  • NgRx organizes state with actions, reducers, selectors, effects.
  • Built a Todo app with form and list using local NgRx state.
  • Extended it with REST API integration using HttpClient and Effects.
  • Store DevTools help debug and time-travel state changes.

20.6 NgRx vs Signals

With Angular v16+, we now have signals, a simpler reactivity model built into the framework. Developers often ask: Should I use Signals or NgRx? The answer depends on app complexity and team needs.


Signals Approach

How it works:

  • Use Angular signals (signal(), computed(), effect()) in services for local or shared state.
  • Changes propagate automatically to the template.
  • Simple, no external library required.

Example:

@Injectable({ providedIn: 'root' })
export class TodoStore {
  todos = signal<Todo[]>([]);

  add(title: string) {
    this.todos.update(list => [
      ...list,
      { id: Date.now(), title, completed: false }
    ]);
  }

  toggle(id: number) {
    this.todos.update(list =>
      list.map(todo =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  }
}

Pros:

  • ✅ Built-in, no extra library.
  • ✅ Very easy to learn.
  • ✅ Fine-grained reactivity (updates only what changes).
  • ✅ Great for small to medium apps or feature modules.

Cons:

  • ❌ No built-in devtools/time-travel debugging.
  • ❌ No formalized patterns (every team may structure differently).
  • ❌ For large teams, scaling can lead to “DIY state management.”

NgRx Approach

How it works:

  • Centralized store that holds global state.
  • State changes only through actions and reducers.
  • Selectors read state; effects handle side effects.

Pros:

  • ✅ Predictable, standardized patterns.
  • ✅ Excellent for large/enterprise apps.
  • ✅ Rich ecosystem: NgRx Store, Effects, Entity, Router Store.
  • ✅ Powerful DevTools (time-travel, action replay).

Cons:

  • ❌ Verbose boilerplate (actions, reducers, effects).
  • ❌ Steeper learning curve.
  • ❌ Can feel “overkill” for smaller apps.

Side-by-Side Comparison

FeatureSignalsNgRx
Setup ComplexityMinimal, built-inHigh (multiple files: actions, reducers)
Learning CurveLow (just signals API)Medium–High (Redux patterns)
Best ForLocal/feature state, small appsGlobal state, enterprise-scale apps
Debugging ToolsConsole/logging onlyNgRx DevTools (time-travel, inspection)
Side Effects HandlingServices + async/awaitNgRx Effects (powerful, testable)
BoilerplateVery lowHigh
Team ConsistencyDepends on conventionsEnforced by NgRx structure

Suggested Guidelines

  • ✅ Use Signals when:

    • You’re building a small or medium app.
    • You just need reactive state for a feature (e.g., form, cart).
    • You prefer simplicity and less boilerplate.
  • ✅ Use NgRx when:

    • You’re working on a large, multi-team enterprise app.
    • State is global and complex (auth, caching, offline sync).
    • You need strict predictability and debugging tools.

20.7 Summary

  • Signals are lightweight, local, reactive state.
  • NgRx is enterprise-scale, centralized state management.
  • They are not mutually exclusive — you can use signals for feature-level state and NgRx for global app state in the same project.