Angular
05 / 10

State Management (NgRx & Signals)

Angular State Management

Angular provides multiple approaches to state management: services with RxJS, NgRx (Redux pattern), and Signals (Angular 16+). Choose based on your application complexity.

Service-Based State (Simple)

import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';

interface AppState {
  user: User | null;
  isLoading: boolean;
  error: string | null;
}

@Injectable({ providedIn: 'root' })
export class StateService {
  private state: AppState = {
    user: null,
    isLoading: false,
    error: null
  };
  
  private stateSubject = new BehaviorSubject<AppState>(this.state);
  public state$ = this.stateSubject.asObservable();
  
  // Selectors
  get user$(): Observable<User | null> {
    return this.state$.pipe(map(state => state.user));
  }
  
  get isLoading$(): Observable<boolean> {
    return this.state$.pipe(map(state => state.isLoading));
  }
  
  // Actions
  setUser(user: User) {
    this.state = { ...this.state, user };
    this.stateSubject.next(this.state);
  }
  
  setLoading(isLoading: boolean) {
    this.state = { ...this.state, isLoading };
    this.stateSubject.next(this.state);
  }
  
  setError(error: string) {
    this.state = { ...this.state, error };
    this.stateSubject.next(this.state);
  }
}

NgRx (Redux Pattern)

NgRx is a Redux-inspired state management library for Angular. It provides a predictable state container with actions, reducers, effects, and selectors.

// 1. Define State
export interface TodoState {
  todos: Todo[];
  loading: boolean;
  error: string | null;
}

export const initialState: TodoState = {
  todos: [],
  loading: false,
  error: null
};

// 2. Create Actions
import { createAction, props } from '@ngrx/store';

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: string }>()
);
export const addTodo = createAction(
  '[Todo] Add Todo',
  props<{ todo: Todo }>()
);
export const deleteTodo = createAction(
  '[Todo] Delete Todo',
  props<{ id: string }>()
);

// 3. Create Reducer
import { createReducer, on } from '@ngrx/store';

export const todoReducer = createReducer(
  initialState,
  on(loadTodos, (state) => ({
    ...state,
    loading: true,
    error: null
  })),
  on(loadTodosSuccess, (state, { todos }) => ({
    ...state,
    todos,
    loading: false
  })),
  on(loadTodosFailure, (state, { error }) => ({
    ...state,
    error,
    loading: false
  })),
  on(addTodo, (state, { todo }) => ({
    ...state,
    todos: [...state.todos, todo]
  })),
  on(deleteTodo, (state, { id }) => ({
    ...state,
    todos: state.todos.filter(t => t.id !== id)
  }))
);

// 4. Create Selectors
import { createFeatureSelector, createSelector } from '@ngrx/store';

export const selectTodoState = createFeatureSelector<TodoState>('todos');

export const selectAllTodos = createSelector(
  selectTodoState,
  (state) => state.todos
);

export const selectActiveTodos = createSelector(
  selectAllTodos,
  (todos) => todos.filter(t => !t.completed)
);

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

export const selectTodosLoading = createSelector(
  selectTodoState,
  (state) => state.loading
);

NgRx Effects

import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { of } from 'rxjs';
import { map, mergeMap, catchError, tap } from 'rxjs/operators';
import * as TodoActions from './todo.actions';

@Injectable()
export class TodoEffects {
  loadTodos$ = createEffect(() =>
    this.actions$.pipe(
      ofType(TodoActions.loadTodos),
      mergeMap(() =>
        this.todoService.getTodos().pipe(
          map(todos => TodoActions.loadTodosSuccess({ todos })),
          catchError(error =>
            of(TodoActions.loadTodosFailure({ error: error.message }))
          )
        )
      )
    )
  );
  
  addTodo$ = createEffect(() =>
    this.actions$.pipe(
      ofType(TodoActions.addTodo),
      mergeMap(action =>
        this.todoService.createTodo(action.todo).pipe(
          map(todo => TodoActions.addTodoSuccess({ todo })),
          catchError(error => of(TodoActions.addTodoFailure({ error })))
        )
      )
    )
  );
  
  // Non-dispatching effect (for side effects only)
  logActions$ = createEffect(
    () =>
      this.actions$.pipe(
        tap(action => console.log('Action:', action))
      ),
    { dispatch: false }
  );
  
  constructor(
    private actions$: Actions,
    private todoService: TodoService
  ) { }
}

Using NgRx in Components

import { Component, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import * as TodoActions from './store/todo.actions';
import * as TodoSelectors from './store/todo.selectors';

@Component({
  selector: 'app-todo-list',
  template: `
    <div>
      <h2>Todos</h2>
      <div *ngIf="loading$ | async">Loading...</div>
      <ul>
        <li *ngFor="let todo of todos$ | async">
          {{ todo.text }}
          <button (click)="delete(todo.id)">Delete</button>
        </li>
      </ul>
      <button (click)="load()">Load Todos</button>
    </div>
  `
})
export class TodoListComponent implements OnInit {
  todos$: Observable<Todo[]>;
  loading$: Observable<boolean>;
  
  constructor(private store: Store) {
    this.todos$ = this.store.select(TodoSelectors.selectAllTodos);
    this.loading$ = this.store.select(TodoSelectors.selectTodosLoading);
  }
  
  ngOnInit() {
    this.load();
  }
  
  load() {
    this.store.dispatch(TodoActions.loadTodos());
  }
  
  delete(id: string) {
    this.store.dispatch(TodoActions.deleteTodo({ id }));
  }
}

Signals (Angular 16+)

Signals provide a reactive primitive for managing state with fine-grained reactivity and better performance than Zone.js.

import { Component, signal, computed, effect } from '@angular/core';

@Component({
  selector: 'app-counter',
  template: `
    <div>
      <p>Count: {{ count() }}</p>
      <p>Double: {{ doubleCount() }}</p>
      <button (click)="increment()">+</button>
      <button (click)="decrement()">-</button>
      <button (click)="reset()">Reset</button>
    </div>
  `
})
export class CounterComponent {
  // Signal - writable
  count = signal(0);
  
  // Computed signal - derived
  doubleCount = computed(() => this.count() * 2);
  
  // Effect - side effects
  constructor() {
    effect(() => {
      console.log('Count changed:', this.count());
      localStorage.setItem('count', this.count().toString());
    });
  }
  
  increment() {
    this.count.update(value => value + 1);
  }
  
  decrement() {
    this.count.update(value => value - 1);
  }
  
  reset() {
    this.count.set(0);
  }
}

// Signal-based service
import { Injectable, signal, computed } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class TodoSignalService {
  private todos = signal<Todo[]>([]);
  
  // Public readonly version
  readonly allTodos = this.todos.asReadonly();
  
  // Computed signals
  readonly activeTodos = computed(() => 
    this.todos().filter(t => !t.completed)
  );
  
  readonly completedTodos = computed(() => 
    this.todos().filter(t => t.completed)
  );
  
  readonly stats = computed(() => ({
    total: this.todos().length,
    active: this.activeTodos().length,
    completed: this.completedTodos().length
  }));
  
  addTodo(todo: Todo) {
    this.todos.update(todos => [...todos, todo]);
  }
  
  deleteTodo(id: string) {
    this.todos.update(todos => todos.filter(t => t.id !== id));
  }
  
  toggleTodo(id: string) {
    this.todos.update(todos =>
      todos.map(t => 
        t.id === id ? { ...t, completed: !t.completed } : t
      )
    );
  }
}

Keep your own version of these notes — editable, searchable, and organised by your stack.

Start free