State Management
Component state, services, NgRx signals, and when to use each
State management in Angular ranges from local component signals to application-wide stores. The right choice depends on how widely state needs to be shared and how complex the update logic is. Angular Signals (17+) have changed the calculus — many apps that previously needed NgRx can now use signals in services.
Key Points
- Component state: signal() — use for UI state that does not leave the component (toggle, count)
- Service state: signal() or BehaviorSubject in an @Injectable service — share state between sibling/unrelated components
- NgRx Store: Redux-pattern — Actions → Reducers → State → Selectors; use when state is complex, global, and needs time-travel debugging
- NgRx Signals Store (@ngrx/signals): lighter NgRx with Signals — withEntities, withMethods, withComputed — Angular 17+
- Zustand / Elf: lightweight reactive stores — simpler than NgRx for medium-complexity state
- Derived state: computed() — automatically re-derives from one or more signals when dependencies change
- Side effects in NgRx: Effects — listen to Actions, call APIs, dispatch new Actions
- Selector memoisation: createSelector() in NgRx — re-runs only when input slice changes
- State immutability: always create new objects/arrays instead of mutating — required for OnPush and NgRx to work correctly
| Approach | Scope | Complexity | Best for |
|---|---|---|---|
| signal() in component | Component only | Minimal | UI toggles, local counters |
| signal() in service | Feature/app-wide | Low | Cart, auth, user preferences |
| NgRx Signals Store | Feature/app-wide | Medium | Entity collections, CRUD pages |
| NgRx Store | App-wide | High | Complex business flows, time-travel debug |
Angular state: signals service for simple state, NgRx Signals Store with entity collection and computed
// 1. Simple service with signals — no NgRx needed for most apps
@Injectable({ providedIn: 'root' })
export class TodoService {
private todos = signal<Todo[]>([]);
readonly all = this.todos.asReadonly();
readonly pending = computed(() => this.todos().filter(t => !t.done));
readonly count = computed(() => this.todos().length);
add(text: string) { this.todos.update(list => [...list, { id: Date.now(), text, done: false }]); }
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)); }
}
// 2. NgRx Signals Store — entity collection with API integration
import { signalStore, withState, withMethods, withComputed, patchState } from '@ngrx/signals';
import { withEntities, setAllEntities, addEntity, removeEntity } from '@ngrx/signals/entities';
export const ProductStore = signalStore(
{ providedIn: 'root' },
withEntities<Product>(), // id-keyed entity collection
withState({ loading: false }),
withComputed(({ entities }) => ({
inStock: computed(() => entities().filter(p => p.stock > 0)),
totalValue: computed(() => entities().reduce((sum, p) => sum + p.price * p.stock, 0))
})),
withMethods((store, api = inject(ProductApi)) => ({
async loadAll() {
patchState(store, { loading: true });
const products = await api.getProducts();
patchState(store, setAllEntities(products), { loading: false });
},
removeProduct: (id: string) => patchState(store, removeEntity(id))
}))
);
// Usage in component
@Component({ standalone: true, ... })
export class ProductListComponent {
store = inject(ProductStore);
ngOnInit() { this.store.loadAll(); }
// template: @for (p of store.inStock(); track p.id)
}Real-World Example
Most Angular apps don't need NgRx. A service with signals handles 80% of state management needs and is far simpler to learn, debug, and test. Reach for NgRx when you need: (1) complex derived state from multiple sources with memoisation guarantees, (2) optimistic updates with rollback, (3) Redux DevTools time-travel debugging, or (4) a team already familiar with the Redux pattern.