State Management in Angular
State Management Options
Angular offers several approaches to state management depending on the complexity of your app:
| Approach | Best for | Complexity |
|---|---|---|
| Component state (signals) | Local UI state | Low |
| Service + Signals | Shared state across components | Low“Medium |
| NgRx Signals Store | Large apps, complex state | Medium“High |
| NgRx (classic) | Enterprise apps, Redux pattern | High |
Pattern 1: Service with Signals (Recommended)
For most apps, a service holding signals is the simplest and most effective approach. No extra libraries needed.
import { Injectable, signal, computed } from '@angular/core';
interface Todo {
id: number;
text: string;
done: boolean;
}
@Injectable({ providedIn: 'root' })
export class TodoStore {
private _todos = signal<Todo[]>([]);
// Public read-only signals
readonly todos = this._todos.asReadonly();
readonly total = computed(() => this._todos().length);
readonly completed = computed(() => this._todos().filter(t => t.done).length);
readonly pending = computed(() => this.total() - this.completed());
add(text: string) {
const todo: Todo = { id: Date.now(), text, done: false };
this._todos.update(list => [...list, todo]);
}
toggle(id: number) {
this._todos.update(list =>
list.map(t => t.id === id ? { ...t, done: !t.done } : t)
);
}
remove(id: number) {
this._todos.update(list => list.filter(t => t.id !== id));
}
clearCompleted() {
this._todos.update(list => list.filter(t => !t.done));
}
}
import { Component, inject, signal } from '@angular/core';
import { TodoStore } from './todo.store';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-todo-list',
standalone: true,
imports: [FormsModule],
template: `
<h2>Todos ({{ store.pending() }} pending)</h2>
<input [(ngModel)]="newTodo" (keydown.enter)="add()" placeholder="Add todo..." />
<button (click)="add()">Add</button>
@for (todo of store.todos(); track todo.id) {
<div [class.done]="todo.done">
<input type="checkbox" [checked]="todo.done" (change)="store.toggle(todo.id)" />
{{ todo.text }}
<button (click)="store.remove(todo.id)">✕</button>
</div>
}
@if (store.completed() > 0) {
<button (click)="store.clearCompleted()">Clear completed</button>
}
`
})
export class TodoListComponent {
store = inject(TodoStore);
newTodo = signal('');
add() {
if (this.newTodo().trim()) {
this.store.add(this.newTodo());
this.newTodo.set('');
}
}
}
Pattern 2: NgRx SignalStore
NgRx SignalStore (from @ngrx/signals) is a lightweight, signal-based state management library. It's the modern NgRx approach for Angular 17+.
npm install @ngrx/signalsimport { signalStore, withState, withComputed, withMethods } from '@ngrx/signals';
import { computed } from '@angular/core';
interface CounterState {
count: number;
step: number;
}
export const CounterStore = signalStore(
{ providedIn: 'root' },
withState<CounterState>({ count: 0, step: 1 }),
withComputed(({ count, step }) => ({
doubled: computed(() => count() * 2),
canReset: computed(() => count() !== 0),
})),
withMethods(({ count, step, ...store }) => ({
increment: () => store.patchState({ count: count() + step() }),
decrement: () => store.patchState({ count: count() - step() }),
reset: () => store.patchState({ count: 0 }),
setStep: (s: number) => store.patchState({ step: s }),
}))
);
import { Component, inject } from '@angular/core';
import { CounterStore } from './counter.store';
@Component({
selector: 'app-counter',
standalone: true,
providers: [CounterStore], // or use providedIn: 'root' in the store
template: `
<p>Count: {{ store.count() }}</p>
<p>Doubled: {{ store.doubled() }}</p>
<button (click)="store.increment()">+</button>
<button (click)="store.decrement()">-</button>
<button (click)="store.reset()" [disabled]="!store.canReset()">Reset</button>
`
})
export class CounterComponent {
store = inject(CounterStore);
}
Pattern 3: Classic NgRx (Redux)
For large enterprise apps that need strict unidirectional data flow, time-travel debugging, and a full audit trail.
import { createAction, props } from '@ngrx/store';
export const increment = createAction('[Counter] Increment');
export const decrement = createAction('[Counter] Decrement');
export const reset = createAction('[Counter] Reset');
export const setCount = createAction('[Counter] Set', props<{ count: number }>());
import { createReducer, on } from '@ngrx/store';
import { increment, decrement, reset, setCount } from './counter.actions';
export interface CounterState { count: number; }
const initialState: CounterState = { count: 0 };
export const counterReducer = createReducer(
initialState,
on(increment, state => ({ ...state, count: state.count + 1 })),
on(decrement, state => ({ ...state, count: state.count - 1 })),
on(reset, state => ({ ...state, count: 0 })),
on(setCount, (state, { count }) => ({ ...state, count }))
);
import { createSelector, createFeatureSelector } from '@ngrx/store';
import { CounterState } from './counter.reducer';
export const selectCounterState = createFeatureSelector<CounterState>('counter');
export const selectCount = createSelector(selectCounterState, s => s.count);
export const selectDoubled = createSelector(selectCount, count => count * 2);