State Management in Angular
As Angular applications grow, state management becomes critical. "State" is any data that determines what your UI looks like: the current user, a product list, a shopping cart, UI flags, and more.
Without a strategy, state scatters across components and services, making bugs hard to reproduce and UI hard to reason about. Angular offers several state management options — from lightweight built-in tools to fully-featured libraries.
Types of State
Type | Description | Example |
|---|---|---|
Local / UI state | Belongs to a single component | dropdown open, form dirty, tab index |
Shared state | Used by multiple components | current user, theme, language |
Server state | Data fetched from an API | product list, user profile |
Router state | Current URL and params | active route, query params |
Form state | Form values and validation | Reactive form model |
State Management Options in Angular
Approach | Complexity | Best For |
|---|---|---|
Component @Input/@Output | Minimal | Simple parent-child data flow |
Services + BehaviorSubject | Low | Small to medium apps, shared state |
Services + Signals | Low | Modern Angular 16+, simple reactive state |
NgRx Store | High | Large teams, complex state, time-travel debugging |
NgRx ComponentStore | Medium | Complex per-component state |
NgRx SignalStore | Medium | Modern signal-based NgRx (Angular 17+) |
Akita / Elf | Medium | Alternative to NgRx with less boilerplate |
Approach 1: Component State (Local)
The simplest approach — store state directly in the component. Good for UI-only state that nothing else needs.
@Component({
selector: 'app-dropdown',
standalone: true,
template: `
<button (click)="toggle()">Menu</button>
@if (isOpen) {
<ul>
<li>Option 1</li>
<li>Option 2</li>
</ul>
}
`,
})
export class DropdownComponent {
isOpen = false;
toggle(): void { this.isOpen = !this.isOpen; }
}Approach 2: Service + BehaviorSubject
For state shared across multiple components, move it into a singleton service backed by a BehaviorSubject. This is the most common pattern for small to medium Angular apps.
// src/app/services/auth.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { HttpClient } from '@angular/common/http';
export interface User {
id: number;
name: string;
email: string;
roles: string[];
}
@Injectable({ providedIn: 'root' })
export class AuthService {
private currentUser$ = new BehaviorSubject<User | null>(null);
readonly user$: Observable<User | null> = this.currentUser$.asObservable();
readonly isLoggedIn$: Observable<boolean> = this.user$.pipe(map(u => !!u));
readonly isAdmin$: Observable<boolean> = this.user$.pipe(
map(u => u?.roles.includes('admin') ?? false)
);
constructor(private http: HttpClient) {
// Rehydrate from storage on startup
const stored = localStorage.getItem('user');
if (stored) this.currentUser$.next(JSON.parse(stored));
}
login(email: string, password: string): Observable<User> {
return this.http.post<User>('/api/auth/login', { email, password }).pipe(
tap(user => {
this.currentUser$.next(user);
localStorage.setItem('user', JSON.stringify(user));
})
);
}
logout(): void {
this.currentUser$.next(null);
localStorage.removeItem('user');
}
getUser(): User | null {
return this.currentUser$.getValue();
}
}Approach 3: Service + Signals (Angular 16+)
Angular Signals offer a simpler reactive primitive for state management with built-in change detection integration.
// src/app/services/theme.service.ts
import { Injectable, signal, computed } from '@angular/core';
type Theme = 'light' | 'dark' | 'system';
@Injectable({ providedIn: 'root' })
export class ThemeService {
// Writable signal — the source of truth
private theme = signal<Theme>('system');
// Computed signal — derived automatically
readonly isDark = computed(() => {
const t = this.theme();
if (t === 'dark') return true;
if (t === 'light') return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
});
// Read-only view
readonly currentTheme = this.theme.asReadonly();
setTheme(theme: Theme): void {
this.theme.set(theme);
localStorage.setItem('theme', theme);
}
toggleDark(): void {
this.theme.update(t => (t === 'dark' ? 'light' : 'dark'));
}
}
// In a component
@Component({
selector: 'app-header',
standalone: true,
template: `
<button (click)="themeService.toggleDark()">
{{ themeService.isDark() ? '☀️' : '🌙' }}
</button>
`,
})
export class HeaderComponent {
themeService = inject(ThemeService);
}async pipe or subscribe needed in templates. This makes signal-based services significantly simpler than Observable-based ones.Immutable State Updates
Regardless of which approach you use, always treat state as immutable — create new objects/arrays rather than mutating existing ones. This enables Angular's OnPush change detection to work correctly.
// BAD — mutating state directly
addProduct(product: Product): void {
this.products$.getValue().push(product); // mutates the array!
// OnPush components won't detect this change
}
// GOOD — immutable update
addProduct(product: Product): void {
const current = this.products$.getValue();
this.products$.next([...current, product]); // new array reference
}
// GOOD — with signals
addProduct(product: Product): void {
this.products.update(list => [...list, product]); // update() creates new array
}
// GOOD — nested object update
updateUserEmail(email: string): void {
this.user.update(u => u ? { ...u, email } : null); // spread to copy
}Choosing the Right Approach
Use this decision tree when choosing a state management approach:
State used by ONE component → keep it local (component property or signal)
State shared across a few components → service with BehaviorSubject or signals
Complex async flows with HTTP → service with BehaviorSubject + RxJS operators
Large team, audit trail, time-travel debugging → NgRx Store
Complex per-component state (e.g., paginated data table) → NgRx ComponentStore
Modern Angular 17+ with signal preference → NgRx SignalStore
State Management Anti-Patterns to Avoid
Storing derived state — compute it with computed() or map() instead of storing it separately
Duplicating state — if the same data lives in two places they'll get out of sync
Mutating state objects directly — always use immutable updates
Putting HTTP calls in components — HTTP logic belongs in services
Subscribing inside subscribe — use switchMap/mergeMap instead
Not unsubscribing from services in components — causes memory leaks
Summary
Angular offers a spectrum of state management solutions. Start simple — local component state for UI-only state, services with BehaviorSubject or Signals for shared state — and only reach for NgRx when the complexity demands it. The most important principles are: single source of truth, immutable updates, and unidirectional data flow — all applicable regardless of which approach you choose.