State Management advanced

Global State Management with Signals

Build a lightweight, type-safe state management solution using Angular Signals.

Alex Muturi
July 17, 2025
22 min read
#signals #state-management #redux #architecture #global-state

Global State Management with Signals

Build a powerful, type-safe state management solution using Angular Signals that rivals Redux while remaining simple and performant.

Complete Solution

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

export interface Action<T = any> {
  type: string;
  payload?: T;
}

export type Reducer<State> = (state: State, action: Action) => State;
export type Selector<State, Result> = (state: State) => Result;

export class SignalStore<State extends object> {
  private state = signal<State>({} as State);
  private reducers: Map<string, Reducer<State>> = new Map();
  private middleware: Array<(action: Action, state: State) => void> = [];
  private actionHistory = signal<Action[]>([]);

  // Public read-only state
  readonly $state: Signal<State> = this.state.asReadonly();

  constructor(initialState?: State) {
    if (initialState) {
      this.state.set(initialState);
    }

    // Dev tools integration
    if (typeof window !== 'undefined' && (window as any).__SIGNAL_DEVTOOLS__) {
      this.setupDevTools();
    }
  }

  // Register reducer for action type
  on<P = any>(actionType: string, reducer: (state: State, payload: P) => State): this {
    this.reducers.set(actionType, (state, action) => reducer(state, action.payload));
    return this;
  }

  // Dispatch action
  dispatch(action: Action): void {
    // Log action
    this.actionHistory.update(history => [...history, action]);

    // Run middleware
    this.middleware.forEach(mw => mw(action, this.state()));

    // Get reducer
    const reducer = this.reducers.get(action.type);

    if (reducer) {
      this.state.update(currentState => reducer(currentState, action));
    } else {
      console.warn(`No reducer found for action type: ${action.type}`);
    }
  }

  // Create selector
  select<Result>(selector: Selector<State, Result>): Signal<Result> {
    return computed(() => selector(this.state()));
  }

  // Add middleware
  use(middleware: (action: Action, state: State) => void): this {
    this.middleware.push(middleware);
    return this;
  }

  // Reset state
  reset(newState: State): void {
    this.state.set(newState);
    this.actionHistory.set([]);
  }

  // Get action history
  getHistory(): Signal<Action[]> {
    return this.actionHistory.asReadonly();
  }

  private setupDevTools(): void {
    effect(() => {
      (window as any).__SIGNAL_DEVTOOLS__.updateState(this.state());
    });
  }
}

// Helper to create typed actions
export function createAction<P = void>(type: string) {
  return (payload: P): Action<P> => ({ type, payload });
}

Example: Todo App with Signal Store

import { Component, computed, ChangeDetectionStrategy } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { SignalStore, createAction } from './signal-store';

// State interface
interface Todo {
  id: string;
  text: string;
  completed: boolean;
  createdAt: number;
}

interface TodoState {
  todos: Todo[];
  filter: 'all' | 'active' | 'completed';
  loading: boolean;
}

// Actions
const TodoActions = {
  addTodo: createAction<string>('ADD_TODO'),
  toggleTodo: createAction<string>('TOGGLE_TODO'),
  removeTodo: createAction<string>('REMOVE_TODO'),
  setFilter: createAction<'all' | 'active' | 'completed'>('SET_FILTER'),
  clearCompleted: createAction<void>('CLEAR_COMPLETED'),
  loadTodos: createAction<Todo[]>('LOAD_TODOS'),
  setLoading: createAction<boolean>('SET_LOADING')
};

// Store setup
@Injectable()
class TodoStore extends SignalStore<TodoState> {
  constructor() {
    super({
      todos: [],
      filter: 'all',
      loading: false
    });

    this
      .on<string>('ADD_TODO', (state, text) => ({
        ...state,
        todos: [
          ...state.todos,
          {
            id: crypto.randomUUID(),
            text,
            completed: false,
            createdAt: Date.now()
          }
        ]
      }))
      .on<string>('TOGGLE_TODO', (state, id) => ({
        ...state,
        todos: state.todos.map(todo =>
          todo.id === id ? { ...todo, completed: !todo.completed } : todo
        )
      }))
      .on<string>('REMOVE_TODO', (state, id) => ({
        ...state,
        todos: state.todos.filter(todo => todo.id !== id)
      }))
      .on<'all' | 'active' | 'completed'>('SET_FILTER', (state, filter) => ({
        ...state,
        filter
      }))
      .on<void>('CLEAR_COMPLETED', state => ({
        ...state,
        todos: state.todos.filter(todo => !todo.completed)
      }))
      .on<Todo[]>('LOAD_TODOS', (state, todos) => ({
        ...state,
        todos,
        loading: false
      }))
      .on<boolean>('SET_LOADING', (state, loading) => ({
        ...state,
        loading
      }));

    // Logger middleware
    this.use((action, state) => {
      console.log('Action:', action.type, action.payload);
      console.log('New State:', state);
    });
  }

  // Selectors
  readonly todos = this.select(state => state.todos);
  readonly filter = this.select(state => state.filter);
  readonly loading = this.select(state => state.loading);

  readonly filteredTodos = computed(() => {
    const todos = this.todos();
    const filter = this.filter();

    switch (filter) {
      case 'active':
        return todos.filter(t => !t.completed);
      case 'completed':
        return todos.filter(t => t.completed);
      default:
        return todos;
    }
  });

  readonly stats = computed(() => {
    const todos = this.todos();
    return {
      total: todos.length,
      active: todos.filter(t => !t.completed).length,
      completed: todos.filter(t => t.completed).length
    };
  });
}

@Component({
  selector: 'app-todo-app',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [FormsModule],
  providers: [TodoStore],
  template: `
    <div class="todo-app">
      <h1>Todo App with Signal Store</h1>

      <div class="add-todo">
        <input
          [(ngModel)]="newTodoText"
          (keyup.enter)="addTodo()"
          placeholder="What needs to be done?"
        />
        <button (click)="addTodo()">Add</button>
      </div>

      <div class="filters">
        <button
          [class.active]="store.filter() === 'all'"
          (click)="setFilter('all')"
        >
          All ({{ store.stats().total }})
        </button>
        <button
          [class.active]="store.filter() === 'active'"
          (click)="setFilter('active')"
        >
          Active ({{ store.stats().active }})
        </button>
        <button
          [class.active]="store.filter() === 'completed'"
          (click)="setFilter('completed')"
        >
          Completed ({{ store.stats().completed }})
        </button>
      </div>

      @if (store.loading()) {
        <div class="loading">Loading...</div>
      }

      <ul class="todo-list">
        @for (todo of store.filteredTodos(); track todo.id) {
          <li [class.completed]="todo.completed">
            <input
              type="checkbox"
              [checked]="todo.completed"
              (change)="toggleTodo(todo.id)"
            />
            <span class="todo-text">{{ todo.text }}</span>
            <button class="delete" (click)="removeTodo(todo.id)">×</button>
          </li>
        }
        @empty {
          <li class="empty">No todos to show</li>
        }
      </ul>

      @if (store.stats().completed > 0) {
        <button class="clear-completed" (click)="clearCompleted()">
          Clear Completed
        </button>
      }
    </div>
  `,
  styles: [`
    .todo-app {
      max-width: 600px;
      margin: 40px auto;
      padding: 20px;
    }

    h1 {
      text-align: center;
      color: #2c3e50;
    }

    .add-todo {
      display: flex;
      gap: 8px;
      margin-bottom: 20px;
    }

    .add-todo input {
      flex: 1;
      padding: 12px;
      border: 2px solid #ddd;
      border-radius: 4px;
      font-size: 16px;
    }

    .add-todo button {
      padding: 12px 24px;
      background: #42b983;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
      font-size: 16px;
    }

    .filters {
      display: flex;
      gap: 8px;
      margin-bottom: 20px;
    }

    .filters button {
      flex: 1;
      padding: 10px;
      background: #f5f5f5;
      border: 1px solid #ddd;
      border-radius: 4px;
      cursor: pointer;
    }

    .filters button.active {
      background: #42b983;
      color: white;
      border-color: #42b983;
    }

    .todo-list {
      list-style: none;
      padding: 0;
    }

    .todo-list li {
      display: flex;
      align-items: center;
      gap: 12px;
      padding: 12px;
      background: white;
      border: 1px solid #ddd;
      border-radius: 4px;
      margin-bottom: 8px;
    }

    .todo-list li.completed .todo-text {
      text-decoration: line-through;
      color: #999;
    }

    .todo-text {
      flex: 1;
    }

    .delete {
      width: 30px;
      height: 30px;
      background: #f44336;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
      font-size: 20px;
    }

    .empty {
      text-align: center;
      color: #999;
      padding: 40px;
    }

    .clear-completed {
      width: 100%;
      padding: 10px;
      background: #f44336;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
      margin-top: 12px;
    }
  `]
})
export class TodoAppComponent {
  newTodoText = '';

  constructor(public store: TodoStore) {}

  addTodo() {
    if (this.newTodoText.trim()) {
      this.store.dispatch(TodoActions.addTodo(this.newTodoText.trim()));
      this.newTodoText = '';
    }
  }

  toggleTodo(id: string) {
    this.store.dispatch(TodoActions.toggleTodo(id));
  }

  removeTodo(id: string) {
    this.store.dispatch(TodoActions.removeTodo(id));
  }

  setFilter(filter: 'all' | 'active' | 'completed') {
    this.store.dispatch(TodoActions.setFilter(filter));
  }

  clearCompleted() {
    this.store.dispatch(TodoActions.clearCompleted());
  }
}

Advanced: Async Actions & Effects

import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { TodoStore, TodoActions } from './todo-store';

@Injectable({ providedIn: 'root' })
export class TodoEffects {
  private http = inject(HttpClient);
  private store = inject(TodoStore);

  async loadTodos() {
    this.store.dispatch(TodoActions.setLoading(true));
    
    try {
      const todos = await this.http.get<Todo[]>('/api/todos').toPromise();
      this.store.dispatch(TodoActions.loadTodos(todos!));
    } catch (error) {
      console.error('Failed to load todos:', error);
      this.store.dispatch(TodoActions.setLoading(false));
    }
  }

  async saveTodo(todo: Todo) {
    try {
      await this.http.post('/api/todos', todo).toPromise();
    } catch (error) {
      console.error('Failed to save todo:', error);
    }
  }
}

Key Takeaways

  1. Type-safe actions with payload inference
  2. Centralized state management
  3. Composable selectors with computed signals
  4. Middleware support for logging, persistence, etc.
  5. Dev tools integration for debugging

Next Steps

References

Share this article