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
- Type-safe actions with payload inference
- Centralized state management
- Composable selectors with computed signals
- Middleware support for logging, persistence, etc.
- Dev tools integration for debugging
Next Steps
- Explore Undo/Redo Pattern
- Learn about Effect Patterns
- Read State Management Best Practices