NgRx Tutorial: State Management in Angular with Real-World Examples

by Didin J. on Aug 31, 2025 NgRx Tutorial: State Management in Angular with Real-World Examples

Learn NgRx state management in Angular with a real-world Todo app. Step-by-step guide on actions, reducers, selectors, effects, and best practices.

When building modern Angular applications, managing state quickly becomes one of the most challenging aspects. As your app grows, you need a reliable way to handle data consistency, synchronize UI with backend APIs, and keep track of user interactions without falling into the trap of scattered services and tangled event emitters.

This is where NgRx comes in. NgRx is a powerful state management library built on top of RxJS and inspired by Redux. It provides a predictable state container that makes your application easier to reason about, debug, and scale. With features like Actions, Reducers, Selectors, and Effects, NgRx helps you manage everything from simple UI state to complex async workflows in a clean and testable way.

In this tutorial, you’ll learn NgRx step by step by building a real-world Todo Manager application using Angular 20+. We’ll cover:

  • Setting up NgRx in a modern Angular project

  • Creating actions, reducers, and selectors for state updates

  • Handling async operations with effects

  • Using NgRx Entity to manage collections efficiently

  • Debugging state changes with Store DevTools

  • Building a polished UI with Angular Material

By the end, you’ll have a clear understanding of how NgRx works and when to use it in your Angular projects.

What you’ll build

A Todo Manager with:

  • Add/toggle/delete todos

  • Async “Load Todos” via an effect (simulated API)

  • Entity adapter for scalable collections

  • Angular Material UI

  • Store DevTools for debugging

Prerequisites

  • Node.js 18+

  • Angular CLI 20+

  • Basic Angular + RxJS knowledge


Create the Angular project

New standalone project (choose SCSS if you like).

ng new ngrx-demo --standalone --routing --style=scss
cd ngrx-demo

Install NgRx core libs.

npm i @ngrx/store @ngrx/effects @ngrx/entity @ngrx/store-devtools

Add Angular Material (select a theme and enable animations).

ng add @angular/material


Project structure (what we’ll add)

We will build an Angular application with the project structure as follows.

src/app/
  app.config.ts
  app.component.ts
  core/
    services/todos.service.ts
  features/todos/
    models/todo.model.ts
    store/todos.actions.ts
    store/todos.reducer.ts
    store/todos.selectors.ts
    store/todos.effects.ts
    components/todo-input.component.ts
    components/todo-input.component.html
    components/todo-input.component.scss
    components/todo-list.component.ts
    components/todo-list.component.html
    components/todo-list.component.scss

Create folders/files as we go.


Quick NgRx refresher (30 seconds)

  • Actions: plain objects that describe “what happened.”

  • Reducer: a pure function that takes the current state + action ⇒ new state.

  • Selectors: functions that derive data from the state.

  • Effects: handle async work and dispatch new actions with the results.

  • Entity: helpers for managing collections (add/update/remove) efficiently.


Model & State (with Entity)

Create src/app/features/todos/models/todo.model.ts:

export interface Todo {
  id: string;
  title: string;
  done: boolean;
  createdAt: string; // ISO string
}


Actions

Create src/app/features/todos/store/todos.actions.ts:

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

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

// Add
export const addTodo = createAction(
  '[Todos] Add Todo',
  props<{ title: string }>()
);
export const addTodoSuccess = createAction(
  '[Todos] Add Todo Success',
  props<{ todo: Todo }>()
);
export const addTodoFailure = createAction(
  '[Todos] Add Todo Failure',
  props<{ error: string }>()
);

// Toggle
export const toggleTodo = createAction(
  '[Todos] Toggle Todo',
  props<{ id: string }>()
);
export const toggleTodoSuccess = createAction(
  '[Todos] Toggle Todo Success',
  props<{ id: string; changes: Partial<Todo> }>()
);
export const toggleTodoFailure = createAction(
  '[Todos] Toggle Todo Failure',
  props<{ error: string }>()
);

// Delete
export const deleteTodo = createAction(
  '[Todos] Delete Todo',
  props<{ id: string }>()
);
export const deleteTodoSuccess = createAction(
  '[Todos] Delete Todo Success',
  props<{ id: string }>()
);
export const deleteTodoFailure = createAction(
  '[Todos] Delete Todo Failure',
  props<{ error: string }>()
);


Reducer with Entity Adapter

Create src/app/features/todos/store/todos.reducer.ts:

import { createReducer, on } from '@ngrx/store';
import {
  addTodoSuccess,
  deleteTodoSuccess,
  loadTodos,
  loadTodosFailure,
  loadTodosSuccess,
  toggleTodoSuccess,
} from './todos.actions';
import { Todo } from '../models/todo.model';
import {
  createEntityAdapter,
  EntityState,
  Update,
} from '@ngrx/entity';

export const TODOS_FEATURE_KEY = 'todos';

export interface TodosState extends EntityState<Todo> {
  loading: boolean;
  error: string | null;
}

export const adapter = createEntityAdapter<Todo>({
  selectId: (t) => t.id,
  sortComparer: (a, b) => b.createdAt.localeCompare(a.createdAt), // newest first
});

export const initialState: TodosState = adapter.getInitialState({
  loading: false,
  error: null,
});

export const todosReducer = createReducer(
  initialState,

  // Load
  on(loadTodos, (state) => ({ ...state, loading: true, error: null })),
  on(loadTodosSuccess, (state, { todos }) =>
    adapter.setAll(todos, { ...state, loading: false })
  ),
  on(loadTodosFailure, (state, { error }) => ({ ...state, loading: false, error })),

  // Add
  on(addTodoSuccess, (state, { todo }) => adapter.addOne(todo, state)),

  // Toggle
  on(toggleTodoSuccess, (state, { id, changes }) =>
    adapter.updateOne({ id, changes } as Update<Todo>, state)
  ),

  // Delete
  on(deleteTodoSuccess, (state, { id }) => adapter.removeOne(id, state))
);

// Expose adapter selectors (we’ll wire these in selectors file)
export const { selectAll, selectEntities, selectIds, selectTotal } = adapter.getSelectors();


Selectors

Create src/app/features/todos/store/todos.selectors.ts:

import { createFeatureSelector, createSelector } from '@ngrx/store';
import { TODOS_FEATURE_KEY, TodosState, selectAll, selectEntities } from './todos.reducer';

export const selectTodosState = createFeatureSelector<TodosState>(TODOS_FEATURE_KEY);

export const selectTodosAll = createSelector(selectTodosState, (state) => selectAll(state));
export const selectTodosEntities = createSelector(selectTodosState, (state) => selectEntities(state));
export const selectTodosLoading = createSelector(selectTodosState, (state) => state.loading);
export const selectTodosError = createSelector(selectTodosState, (state) => state.error);

export const selectCompletedTodos = createSelector(
  selectTodosAll,
  (todos) => todos.filter((t) => t.done)
);

export const selectActiveTodos = createSelector(
  selectTodosAll,
  (todos) => todos.filter((t) => !t.done)
);

export const selectCounts = createSelector(
  selectTodosAll,
  (todos) => {
    const completed = todos.filter((t) => t.done).length;
    return { total: todos.length, completed, active: todos.length - completed };
  }
);


Simulated API Service

Create src/app/core/services/todos.service.ts:

import { Injectable } from '@angular/core';
import { Observable, of, throwError, delay, map } from 'rxjs';
import { Todo } from '../../features/todos/models/todo.model';

@Injectable({ providedIn: 'root' })
export class TodosService {
  // Simulated backend state (in-memory)
  private data: Record<string, Todo> = {};

  getTodos(): Observable<Todo[]> {
    // seed a few if empty
    if (Object.keys(this.data).length === 0) {
      const seed: Todo[] = [
        this.make('Write NgRx tutorial'),
        this.make('Refactor components to standalone'),
        this.make('Ship demo with Material'),
      ];
      seed.forEach((t) => (this.data[t.id] = t));
    }
    return of(Object.values(this.data)).pipe(delay(400));
  }

  add(title: string): Observable<Todo> {
    if (!title.trim()) return throwError(() => new Error('Title is required'));
    const todo = this.make(title);
    this.data[todo.id] = todo;
    return of(todo).pipe(delay(250));
  }

  toggle(id: string): Observable<{ id: string; changes: Partial<Todo> }> {
    const todo = this.data[id];
    if (!todo) return throwError(() => new Error('Todo not found'));
    const changes = { done: !todo.done };
    this.data[id] = { ...todo, ...changes };
    return of({ id, changes }).pipe(delay(200));
  }

  delete(id: string): Observable<{ id: string }> {
    if (!this.data[id]) return throwError(() => new Error('Todo not found'));
    delete this.data[id];
    return of({ id }).pipe(delay(200));
  }

  // helpers
  private make(title: string): Todo {
    return {
      id: cryptoRandomId(),
      title: title.trim(),
      done: false,
      createdAt: new Date().toISOString(),
    };
  }
}

// Simple unique id generator (no external deps)
function cryptoRandomId(): string {
  // Prefer crypto if available (browser env)
  if ('crypto' in globalThis && 'getRandomValues' in crypto) {
    const arr = new Uint8Array(16);
    crypto.getRandomValues(arr);
    return Array.from(arr, (b) => b.toString(16).padStart(2, '0')).join('');
  }
  // Fallback
  return Math.random().toString(36).slice(2) + Date.now().toString(36);
}


Effects (async flows)

Create src/app/features/todos/store/todos.effects.ts:

import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import {
  addTodo, addTodoFailure, addTodoSuccess,
  deleteTodo, deleteTodoFailure, deleteTodoSuccess,
  loadTodos, loadTodosFailure, loadTodosSuccess,
  toggleTodo, toggleTodoFailure, toggleTodoSuccess
} from './todos.actions';
import { TodosService } from '../../../core/services/todos.service';
import { catchError, map, mergeMap, of, switchMap } from 'rxjs';

@Injectable()
export class TodosEffects {
  constructor(private actions$: Actions, private api: TodosService) {}

  load$ = createEffect(() =>
    this.actions$.pipe(
      ofType(loadTodos),
      switchMap(() =>
        this.api.getTodos().pipe(
          map((todos) => loadTodosSuccess({ todos })),
          catchError((err) => of(loadTodosFailure({ error: err.message ?? 'Load failed' })))
        )
      )
    )
  );

  add$ = createEffect(() =>
    this.actions$.pipe(
      ofType(addTodo),
      mergeMap(({ title }) =>
        this.api.add(title).pipe(
          map((todo) => addTodoSuccess({ todo })),
          catchError((err) => of(addTodoFailure({ error: err.message ?? 'Add failed' })))
        )
      )
    )
  );

  toggle$ = createEffect(() =>
    this.actions$.pipe(
      ofType(toggleTodo),
      mergeMap(({ id }) =>
        this.api.toggle(id).pipe(
          map(({ id, changes }) => toggleTodoSuccess({ id, changes })),
          catchError((err) => of(toggleTodoFailure({ error: err.message ?? 'Toggle failed' })))
        )
      )
    )
  );

  delete$ = createEffect(() =>
    this.actions$.pipe(
      ofType(deleteTodo),
      mergeMap(({ id }) =>
        this.api.delete(id).pipe(
          map(({ id }) => deleteTodoSuccess({ id })),
          catchError((err) => of(deleteTodoFailure({ error: err.message ?? 'Delete failed' })))
        )
      )
    )
  );
}


Wire up NgRx in the app

Open src/app/app.config.ts and register the store, feature state, effects, devtools, material animations:

import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';

import { routes } from './app.routes';
import { provideHttpClient } from '@angular/common/http';
import { provideEffects } from '@ngrx/effects';
import { provideStore, provideState } from '@ngrx/store';
import { TodosEffects } from './features/todos/store/todos.effects';
import { TODOS_FEATURE_KEY, todosReducer } from './features/todos/store/todos.reducer';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { provideAnimations } from '@angular/platform-browser/animations';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideRouter(routes),
    provideHttpClient(),
    provideAnimations(),
    // NgRx
    provideStore(), // root
    provideState(TODOS_FEATURE_KEY, todosReducer),
    provideEffects([TodosEffects]),
    provideStoreDevtools({
      maxAge: 25,
      trace: false,
      connectInZone: true,
      autoPause: true,
    }),
  ]
};

If you have an environment.production flag, you can conditionally enable DevTools.


UI Components with Angular Material

1) Todo Input

Create src/app/features/todos/components/todo-input.component.ts:

import { Component, inject, signal } from "@angular/core";
import { Store } from "@ngrx/store";
import { addTodo } from "../store/todos.actions";
import { FormsModule, ReactiveFormsModule, FormControl, Validators } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';

@Component({
  selector: 'app-todo-input',
  imports: [FormsModule, ReactiveFormsModule, MatFormFieldModule, MatInputModule, MatButtonModule, MatIconModule],
  templateUrl: './todo-input.component.html',
  styleUrl: './todo-input.component.scss'
})
export class TodoInputComponent {
  private store = inject(Store);
  titleCtrl = new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.minLength(3)] });
  submitting = signal(false);

  submit() {
    const title = this.titleCtrl.value.trim();
    if (!title) return;
    this.submitting.set(true);
    this.store.dispatch(addTodo({ title }));
    this.titleCtrl.reset('');
    this.submitting.set(false);
  }
}

Create src/app/features/todos/components/todo-input.component.html:

<form (ngSubmit)="submit()" class="todo-input">
  <mat-form-field appearance="outline" class="flex-1">
    <mat-label>Add a new todo</mat-label>
    <input matInput [formControl]="titleCtrl" placeholder="e.g. Write NgRx tutorial" />
    <mat-error *ngIf="titleCtrl.invalid && (titleCtrl.dirty || titleCtrl.touched)">
      Title is required (min 3).
    </mat-error>
  </mat-form-field>

  <button mat-flat-button color="primary" type="submit" [disabled]="titleCtrl.invalid">
    <mat-icon>add</mat-icon>
    Add
  </button>
</form>

Create src/app/features/todos/components/todo-input.component.scss:

.todo-input {
  display: flex;
  gap: 12px;
  align-items: center;
  margin-bottom: 12px;
}

.flex-1 {
  flex: 1;
}

2) Todo List

Create src/app/features/todos/components/todo-list.component.ts:

import { Component, OnInit, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDividerModule } from '@angular/material/divider';
import { MatTooltipModule } from '@angular/material/tooltip';
import { loadTodos, toggleTodo, deleteTodo } from '../store/todos.actions';
import { selectTodosLoading, selectCounts, selectActiveTodos, selectCompletedTodos } from '../store/todos.selectors';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatListModule } from '@angular/material/list';
import { AsyncPipe, DatePipe, NgFor, NgIf } from '@angular/common';

@Component({
  selector: 'app-todo-list',
  imports: [
    AsyncPipe, DatePipe, NgIf, NgFor,
    MatListModule, MatCheckboxModule, MatIconModule, MatButtonModule, MatProgressBarModule, MatDividerModule, MatTooltipModule
  ],
  templateUrl: './todo-list.component.html',
  styleUrl: './todo-list.component.scss'
})
export class TodoListComponent implements OnInit {
  private store = inject(Store);

  loading$ = this.store.select(selectTodosLoading);
  counts$ = this.store.select(selectCounts);
  active$ = this.store.select(selectActiveTodos);
  completed$ = this.store.select(selectCompletedTodos);

  ngOnInit(): void {
    this.store.dispatch(loadTodos());
  }

  toggle(id: string) {
    this.store.dispatch(toggleTodo({ id }));
  }
  remove(id: string) {
    this.store.dispatch(deleteTodo({ id }));
  }
}

Create src/app/features/todos/components/todo-list.component.html:

<ng-container *ngIf="loading$ | async">
  <mat-progress-bar mode="indeterminate"></mat-progress-bar>
</ng-container>
<div class="counts" *ngIf="counts$ | async as c">
  <strong>Total:</strong> {{ c.total }} • <strong>Active:</strong> {{ c.active }} •
  <strong>Completed:</strong> {{ c.completed }}
</div>

<h3>Active</h3>
<mat-list role="list">
  <mat-list-item role="listitem" *ngFor="let t of active$ | async">
    <mat-checkbox
      (change)="toggle(t.id)"
      [checked]="t.done"
      [disableRipple]="true"
      matTooltip="Mark done"
    ></mat-checkbox>

    <div class="title">
      {{ t.title }}
      <div class="sub">Created {{ t.createdAt | date : 'short' }}</div>
    </div>

    <button mat-icon-button color="warn" aria-label="Delete" (click)="remove(t.id)">
      <mat-icon>delete</mat-icon>
    </button>
  </mat-list-item>
</mat-list>

<mat-divider></mat-divider>

<h3>Completed</h3>
<mat-list role="list">
  <mat-list-item role="listitem" *ngFor="let t of completed$ | async">
    <mat-checkbox
      (change)="toggle(t.id)"
      [checked]="t.done"
      [disableRipple]="true"
      matTooltip="Mark active"
    ></mat-checkbox>

    <div class="title done">
      {{ t.title }}
      <div class="sub">Created {{ t.createdAt | date : 'short' }}</div>
    </div>

    <button mat-icon-button color="warn" aria-label="Delete" (click)="remove(t.id)">
      <mat-icon>delete</mat-icon>
    </button>
  </mat-list-item>
</mat-list>

Create src/app/features/todos/components/todo-list.component.scss:

:host {
  display: block;
}

.counts {
  margin: 8px 0 16px;
}

mat-list-item {
  gap: 12px;
}

.title {
  flex: 1;
}

.title.done {
  text-decoration: line-through;
  opacity: 0.7;
}

.sub {
  font-size: 11px;
  opacity: 0.75;
}

h3 {
  margin: 16px 0 8px;
  font-weight: 600;
}

Prefer signals? With NgRx 16+, you can use store.selectSignal(selector) and bind directly in the template. The rest of the code stays the same.


Compose the App Shell

Open src/app/app.ts and use the two components:

import { Component, signal } from '@angular/core';
import { TodoInputComponent } from './features/todos/components/todo-input.component';
import { TodoListComponent } from './features/todos/components/todo-list.component';

import { MatToolbarModule } from '@angular/material/toolbar';
import { MatCardModule } from '@angular/material/card';

@Component({
  selector: 'app-root',
  imports: [TodoInputComponent, TodoListComponent, MatToolbarModule, MatCardModule],
  templateUrl: './app.html',
  styleUrl: './app.scss'
})
export class App {
  protected readonly title = signal('ngrx-demo');
}

Open src/app/app.html and update:

<mat-toolbar color="primary">NgRx Todo Manager</mat-toolbar>

<main class="container">
  <mat-card>
    <app-todo-input></app-todo-input>
    <app-todo-list></app-todo-list>
  </mat-card>
</main>

Open src/app/app.scss and update:

.container {
  max-width: 880px;
  margin: 24px auto;
  padding: 0 16px;
}

mat-card {
  padding: 16px;
}

That’s it! Run the app:

ng serve -o

You should be able to add, toggle, and delete todos. The initial list is loaded via an Effect from the simulated service.


Debugging with Store DevTools

  1. Install the Redux DevTools browser extension.

  2. With provideStoreDevtools already configured, open DevTools:

    • Inspect each action (e.g., [Todos] Add Todo) and the resulting state.

    • Time-travel: jump between states to verify reducer behavior.

  3. In production, gate DevTools behind an env flag.


Going further (quick wins)

  • Filter/Sort via selectors only (keep reducers pure).

  • Optimistic updates: update state on request, roll back on failure.

  • Facade pattern: expose a single service wrapping store interactions for a cleaner component API.

  • Router Store: integrate route params into selectors for detail pages.

  • Persist state: pair NgRx with localStorage (meta-reducers) for offline.


Common pitfalls & best practices

  • Keep reducers pure: no side effects, no async, no services inside.

  • Prefer selectors to compute derived data; don’t compute inside components.

  • Use Entity for collections—it simplifies immutable updates and scales.

  • Co-locate feature files under features/<feature>/store to keep things modular.

  • Treat Effects as orchestration layers: map request → success/failure actions, handle errors.


Recap

You built a production-style NgRx feature using:

  • Store + Effects + Entity

  • Standalone Angular components

  • Angular Material UI

  • Store DevTools

From here, you can plug in a real API (swap the simulated service with HttpClient) and expand features (editing titles, filtering tabs, pagination, etc.).

You can get the full source code on our GitHub.

If you don’t want to waste your time designing your front-end or your budget to spend by hiring a web designer, then Angular Templates is the best place to go. So, speed up your front-end web development with premium Angular templates. Choose your template for your front-end project here.

That's just the basics. If you need more deep learning about Angular, you can take the following cheap course:

Thanks!