State Management Overview
Every non-trivial Angular application has to answer one question: where does the data live, and who is allowed to change it? State management is the set of patterns that answer that question consistently. Get it wrong and you end up with components silently overwriting each other, stale views, and bugs that only appear in one corner of the app. This page surveys the strategies available in modern Angular — from a single signal in a service to a full Redux-style store — so you can pick the smallest tool that solves your problem.
What counts as state
State is any data that influences what the user sees or how the app behaves. It comes in a few flavours, and they often want different tools:
| Kind of state | Examples | Typical home |
|---|---|---|
| Local UI state | a toggled accordion, a form draft | the component itself (signal) |
| Shared app state | the logged-in user, theme, cart | an injectable service |
| Server cache state | a list fetched from an API | a service + HTTP, or a query library |
| Router state | the active route, query params | Angular Router |
Tip: A surprising amount of “state” is really server cache. Don’t reach for a global store just to hold the response of one HTTP call — a service usually does the job.
The spectrum of solutions
Angular gives you a sliding scale of power and ceremony. Going right adds structure and tooling at the cost of more boilerplate.
component signal → signal service → RxJS data service → NgRx SignalStore → NgRx Store + Effects
(simplest) (most structured)
Component-local signals
For state that no other component cares about, keep it in the component. Signals make this reactive and zoneless-friendly.
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<button (click)="increment()">Clicked {{ count() }} times</button>
@if (count() > 4) {
<p>That's a lot of clicks ({{ doubled() }} doubled).</p>
}
`,
})
export class CounterComponent {
protected readonly count = signal(0);
protected readonly doubled = computed(() => this.count() * 2);
increment(): void {
this.count.update((n) => n + 1);
}
}
Signal-based services
When two or more components need the same data, lift it into an injectable service and expose signals. This is the modern default for shared state in small-to-medium apps.
import { Injectable, signal, computed } from '@angular/core';
export interface CartItem {
id: string;
name: string;
price: number;
qty: number;
}
@Injectable({ providedIn: 'root' })
export class CartService {
private readonly items = signal<CartItem[]>([]);
readonly lines = this.items.asReadonly();
readonly total = computed(() =>
this.items().reduce((sum, i) => sum + i.price * i.qty, 0),
);
add(item: CartItem): void {
this.items.update((list) => [...list, item]);
}
clear(): void {
this.items.set([]);
}
}
Any component can inject(CartService) and read cart.total() in its template. The signal is the single source of truth, and mutations only happen through the service’s methods.
RxJS data services
When state is driven by streams — debounced search, WebSocket feeds, combining several async sources — RxJS still shines. A BehaviorSubject (or a signal bridged with toObservable) holds the value, and operators compose the pipeline.
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, switchMap } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class ProductSearchService {
private readonly http = inject(HttpClient);
private readonly query$ = new BehaviorSubject<string>('');
readonly results$ = this.query$.pipe(
switchMap((q) => this.http.get<string[]>(`/api/products?q=${q}`)),
);
search(term: string): void {
this.query$.next(term);
}
}
NgRx for large apps
Once state is touched from many features, has complex transitions, or you need time-travel debugging and strict auditability, a Redux-style store earns its keep. NgRx offers two flavours:
- NgRx Store + Effects — the classic Redux model: actions, reducers, selectors, and effects for side effects. Maximum structure and tooling.
- NgRx SignalStore — a lighter, signals-first store with less boilerplate that fits the modern Angular grain.
import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';
export const CounterStore = signalStore(
{ providedIn: 'root' },
withState({ count: 0 }),
withMethods((store) => ({
increment: () => patchState(store, (s) => ({ count: s.count + 1 })),
reset: () => patchState(store, { count: 0 }),
})),
);
How to choose
The right answer is almost always “the simplest thing that works.” Use this rough guide:
| Situation | Reach for |
|---|---|
| State used by one component | A component signal |
| Shared across a few components | A signal service |
| Stream-heavy / async orchestration | An RxJS data service |
| Many features, complex transitions | NgRx SignalStore |
| Large team, strict patterns, devtools | NgRx Store + Effects |
Warning: Adding NgRx to a small app is a common over-engineering trap. The boilerplate slows you down without paying off until the state graph is genuinely large and shared.
Best Practices
- Start with signals in components and services; only escalate to a store when sharing and complexity demand it.
- Keep state mutations behind methods on a service or store — never let components write to shared state directly.
- Treat server responses as cache, not application state; cache invalidation is a separate concern from UI state.
- Expose state as
readonlysignals (asReadonly()) so consumers can read but not mutate. - Derive, don’t duplicate: use
computed()and selectors instead of storing values you can calculate. - Pick one primary approach per feature; mixing many patterns in the same area makes the data flow hard to follow.