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
- Store – a single, centralized state tree for your app.
- Actions – plain objects that describe what happened.
- Reducers – pure functions that define how state changes.
- Selectors – functions to read pieces of state.
- 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
HttpClientandEffects. - 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
| Feature | Signals | NgRx |
|---|---|---|
| Setup Complexity | Minimal, built-in | High (multiple files: actions, reducers) |
| Learning Curve | Low (just signals API) | Medium–High (Redux patterns) |
| Best For | Local/feature state, small apps | Global state, enterprise-scale apps |
| Debugging Tools | Console/logging only | NgRx DevTools (time-travel, inspection) |
| Side Effects Handling | Services + async/await | NgRx Effects (powerful, testable) |
| Boilerplate | Very low | High |
| Team Consistency | Depends on conventions | Enforced 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.