TL;DR — We rebuilt Mattrx's state layer (Angular 19, 22k LOC TS, marketing analytics SaaS) over 8 months: state-management LOC 8,400 → 3,100 (-63%), state-related bundle 38 KB → 18 KB, /campaigns re-renders per keystroke 47 → 3. The win wasn't picking the "right" library. It was picking the right library for each kind of state.
👉 Full deep-dive (same /campaigns feature implemented six ways with code, bundle table, LOC counts, re-render measurements, and the Mattrx production layout): https://prepstack.co.in/blog/angular-state-management-comparison-ngrx-signals-ngxs-akita-guide
The mental model first
Most teams treat state as one thing. There are four kinds, and each has a different right answer:
- Local UI state — dropdown open, active tab, current input → Signal in the component
- Server cache — list of campaigns from the API → toSignal(http.get(...)) or NgRx Entity
- Shared feature state — selected rows, bulk-edit draft → Signal in a feature service
- App-wide workflow — multi-step state with audit log, time-travel, DevTools → NgRx (SignalStore or classic)
Trying to use one library for all four — which everyone tried with NgRx around 2019 — is what generated the "NgRx is too much boilerplate" backlash. The library wasn't wrong; the scope it was applied to was.
The same /campaigns feature, 6 ways
| Approach | Bundle (gzip) | LOC | Files | Mattrx verdict |
|---|---|---|---|---|
| Pure Signals | 0 KB | 70 | 2 | Default — covers 80% of state in Mattrx |
| NgRx SignalStore | ~6 KB | 75 | 2 | Where new NgRx code goes in 2026 |
| Akita | ~10 KB | 100 | 4 | Deprecated (maintenance mode since 2023) |
| NGXS | ~14 KB | 140 | 2 | Not adopted — no concrete win over SignalStore |
| NgRx ComponentStore | ~5 KB | — | — | Feature-local heavy workflows |
| NgRx classic | ~25 KB (+Effects+Entity) | 210 | 5 | Audit-trailed mutations, classic Redux pattern |
Re-render granularity (the surprising win)
The big jump isn't picking NgRx vs Signals — it's moving to Signals at all, regardless of library wrapping them:
| Approach | Re-renders / keystroke |
|---|---|
BehaviorSubject + ` |
async` |
NgRx classic + select Observable |
47 (one per consumer) |
NgRx classic + store.selectSignal()
|
3 |
| NgRx SignalStore | 3 |
| Pure Signals | 3 |
store.selectSignal() on classic NgRx gives you the same re-render granularity as pure Signals. So if you're already on NgRx classic, you don't need to migrate — just switch your selectors.
Pure Signals — the 2026 default
The whole /campaigns service in 70 lines:
@Injectable({ providedIn: 'root' })
export class CampaignsService {
private http = inject(HttpClient);
// Box 1 — local UI state
readonly query = signal('');
readonly selected = signal<Set<string>>(new Set());
// Box 2 — server cache (debounced via RxJS at boundary)
readonly campaigns = toSignal(
toObservable(this.query).pipe(
debounceTime(200),
distinctUntilChanged(),
switchMap(q => this.http.get<Campaign[]>(`/api/campaigns?q=${encodeURIComponent(q)}`)),
),
{ initialValue: [] as Campaign[] },
);
readonly filtered = computed(() => this.campaigns());
readonly selectedCount = computed(() => this.selected().size);
readonly canBulkAct = computed(() => this.selectedCount() > 0);
archive(id: string) {
// optimistic — http.post + roll back on error
this.http.post(`/api/campaigns/${id}/archive`, {}).subscribe({ error: () => {} });
}
toggleSelect(id: string) {
this.selected.update(s => {
const next = new Set(s);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
}
That's the whole shared state layer. No actions, no reducers, no selectors, no effects, no entity adapter.
When NgRx still wins
For the /campaigns workflow (queue, retry, audit log surfaced in /inbox), Mattrx kept NgRx — specifically the SignalStore flavor:
export const CampaignsStore = signalStore(
{ providedIn: 'root' },
withState({ items: [] as Campaign[], selected: new Set<string>() }),
withMethods((store, api = inject(CampaignsApi)) => ({
async archive(id: string) {
const before = store.items();
patchState(store, { items: before.filter(c => c.id !== id) });
try { await api.archive(id); }
catch { patchState(store, { items: before }); }
},
})),
withHooks({ onInit: store => /* load + websocket subscribe */ }),
);
Same DevTools (withDevtools()), same time-travel, but the boilerplate gap with pure Signals is now 5 LOC, not 140.
The decision tree
START — "where does this state belong?"
│
▼
Is the state local to ONE component and dies with it?
├── YES → SIGNAL in the component
│
└── NO → Is it server data (fetched, cached, invalidated)?
├── YES → toSignal(http.get(...)) — or NgRx Entity if complex
│
└── NO → Is it shared across components in one feature?
├── YES, simple → SIGNAL in a feature service
├── YES, complex workflow → NgRx ComponentStore
│
└── NO → Shared ACROSS features with audit / DevTools / time-travel?
├── YES, new code → NgRx SignalStore
├── YES, legacy code → NgRx classic
└── YES, on NGXS today → stay on NGXS
NO? → you may not need a store
The Mattrx production layout (after the cleanup)
apps/customer/
├── core/auth → Signal<User> (pure Signal)
├── core/config → Signal<Config> (pure Signal)
├── features/dashboard → Signals + computed (no library)
├── features/campaigns → NgRx SignalStore (workflow + DevTools needed)
├── features/inbox → Signals + RxJS (WebSocket via toSignal)
├── features/inbox/archive-search → Akita (LEGACY — migrating to SignalStore)
├── features/reports → NgRx ComponentStore (heavy local workflow)
├── features/settings-team → Signals (simple forms)
├── features/settings-billing → NgRx classic (audit log + cross-feature notifications)
└── shared/data-access → toSignal(http.get(...)) wrappers
Three NgRx flavors + Signals + one legacy Akita feature. That's fine. The lesson isn't to standardize on one library — it's to pick the minimum viable abstraction per kind of state.
The 2026 mental rule of thumb
Signals for state. RxJS for streams. NgRx SignalStore when the state is shared, workflow-heavy, and benefits from DevTools / time-travel.
Full writeup with all 6 code samples (classic NgRx with Actions+Reducers+Effects, NGXS with @State decorator, Akita query API, ComponentStore for feature-local), the complete bundle table, re-render benchmarks, DevTools comparison, and the Mattrx state-cleanup numbers:
👉 https://prepstack.co.in/blog/angular-state-management-comparison-ngrx-signals-ngxs-akita-guide
If this saved you an argument in a code review, a ❤️ or 🦄 helps it reach more Angular devs.
What state library does your Angular app use today, and would you pick the same in 2026?
Top comments (0)