Angular state can stay local, live in a signal-based service, use NgRx SignalStore, or follow classic NgRx event and reducer patterns. Choose the smallest model that preserves ownership, testability, and predictable updates.
State management begins with ownership, not a library. Keep temporary widget state in its component, share feature state through the nearest common service or route provider, and promote state to an application store only when unrelated areas need coordinated updates.
Separate server state from client state. A product list fetched from an API needs loading, error, refresh, and cache rules; a selected tab is local UI state. Combining both into one undifferentiated object makes invalid transitions easier to create.
Choose a single write path for important state, expose read-only selectors or signals to consumers, and keep derived values computed rather than synchronized manually.
| 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 |
A service holding private writable signals and public read-only signals is often enough for one shared feature. Methods name allowed transitions, computed values derive views, and provider placement controls whether the state is application-wide or feature-scoped.
Keep asynchronous effects at the service boundary and model idle, loading, success, and failure explicitly. Do not let every component call set on shared writable state because that removes the transition contract.
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)">x</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('');
}
}
}
NgRx SignalStore from @ngrx/signals composes state, computed values, methods, and lifecycle features into a store. It is useful when a service would accumulate repeated store plumbing or when teams need consistent feature composition.
Adopt it for a concrete benefit such as reusable store features, entity helpers, event integration, or team conventions. A package is not automatically simpler than a focused signal service for small state.
npm install @ngrx/signals
import { 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);
}
Classic NgRx Store uses dispatched actions, pure reducers, memoized selectors, and effects for external work. Its explicit event log and deterministic updates help large teams trace complex workflows, at the cost of more concepts and files.
Use actions to describe events rather than setters, keep reducers pure, and put HTTP or storage work in effects. Do not choose global Store for isolated form fields or a component toggle.
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);
Any component can bypass add, toggle, validation, persistence, or logging and replace the list directly. Exposing a read-only signal keeps reads convenient while forcing writes through named store methods. That makes state changes easier to find and test.
A provider on the component creates a store instance for that component injector and its descendants.
Classic NgRx earns its ceremony when many features coordinate through explicit events, teams need a durable audit trail, effects orchestrate complex asynchronous workflows, and replayable immutable transitions materially improve debugging.
Explore 500+ free tutorials across 20+ languages and frameworks.