Angular State Management Comparison 2026 — NgRx, Signals, NGXS, and Akita (Real Code, Bundle Numbers, Production Metrics)
NgRx (classic + ComponentStore + SignalStore), Signals, NGXS, Akita — same Mattrx feature implemented every way with bundle, LOC, and DevTools tradeoffs.
- Author
- Randhir Jassal
- Published
- Reading time
- 25 min read
- Views
- 1 views
Angular State Management Comparison 2026 — NgRx, Signals, NGXS, and Akita (Real Code, Bundle Numbers, Production Metrics)
"Which Angular state library should we use?" is a question the answer to which has changed three times in five years. In 2021 the right answer was almost always NgRx. In 2023 most teams should have stopped at services with
BehaviorSubject. In 2026 the answer depends on what kind of state you mean — local UI state, server cache, complex async workflows, multi-feature shared state — and most apps end up using two approaches at once: Signals for the 80% case, NgRx (SignalStore or classic) for the 20% that needs serious workflow modeling.This guide is the apples-to-apples comparison. The same feature — Mattrx's
/campaignspage (list, filter, select, archive, audit log) — implemented four times across pure Signals, NgRx (classic + ComponentStore + SignalStore), NGXS, and Akita. Each version is real code. Each comes with a bundle-size measurement, lines-of-code count, and the operational story (DevTools, time-travel, testing, learning curve). Plus the honest summary: what we actually shipped in Mattrx production, and why.
TL;DR
| Library | Best for | Bundle (gzip) | Mattrx verdict |
|---|---|---|---|
| Pure Signals | Component + service state, derived values, simple shared state | 0 KB (built-in) | Default — covers 80% of state in Mattrx |
| NgRx (classic) | Complex async workflows, multi-feature shared state, audit-trailed mutations | ~25 KB | Used for /campaigns workflow + /inbox event log |
| NgRx ComponentStore | Feature-local state that needs RxJS effects but isn't worth global | ~5 KB (in addition to classic) | Used for /reports/builder (heavy local workflow) |
| NgRx SignalStore | The modern "NgRx without the boilerplate" path | ~6 KB | Where new NgRx code goes in 2026 at Mattrx |
| NGXS | Smaller-boilerplate alternative to classic NgRx | ~14 KB | Not adopted — no concrete win over NgRx SignalStore |
| Akita | Legacy code (Akita is in maintenance mode since 2023) | ~10 KB | Deprecated. One Mattrx feature still on it; being migrated |
The 2026 mental rule of thumb (refined from the Signals vs RxJS guide):
Signals for state. RxJS for streams. NgRx SignalStore when the state is shared, workflow-heavy, and benefits from DevTools / time-travel.
Mattrx production numbers from the state-library cleanup (Angular 16 → 19, 8 months):
- Total state-management LOC: 8,400 → 3,100 (−63%)
- Total state-related bundle (gzipped): 38 KB → 18 KB
- Average
/campaignsfeature renders per filter keystroke: 47 → 3 (Signals + OnPush) - Mean time to debug a state bug (Sentry → fix → ship): 1.4 days → 0.5 day (DevTools time-travel on NgRx kept; everything else simpler)
- "Where does this state live?" PR comments / week: ~14 → ~2
The win wasn't picking the "right" library. It was picking the right library for each kind of state.
1. The mental model — there are four kinds of state
The first mistake every team makes: treating state as one thing. There are at least four kinds in any non-trivial Angular app, and each has a different right answer.
┌──────────────────────────────────────────────────────────────────────────┐
│ │
│ 1. LOCAL UI STATE │
│ "Is this dropdown open? Which tab is active? What did the user type?" │
│ Lives in: ONE component, dies when it does. │
│ Right answer: SIGNAL inside the component │
│ │
│ 2. SERVER CACHE │
│ "What is the current list of campaigns from the API?" │
│ Lives in: a service. Refetched, cached, invalidated. │
│ Right answer: toSignal(http.get(...)) — or NgRx Entity if it's complex │
│ │
│ 3. SHARED FEATURE STATE │
│ "Which rows are selected? What's the current bulk-edit draft?" │
│ Lives in: a service, multiple components subscribe. │
│ Right answer: SIGNAL in a feature service — until it isn't enough │
│ │
│ 4. APP-WIDE WORKFLOW STATE │
│ "Track this multi-step campaign creation through 7 backend transitions │
│ with audit log, retry on failure, persistence to localStorage, │
│ time-travel debugging, redux DevTools." │
│ Lives in: a store, cross-feature, history-aware. │
│ Right answer: NgRx (SignalStore for new code; classic if you need │
│ full Redux pattern + Effects) │
│ │
└──────────────────────────────────────────────────────────────────────────┘
The libraries on the market are good answers to different boxes:
- Signals are the right answer for boxes 1, 2, and 3.
- NgRx (SignalStore or classic) is the right answer for box 4.
- NGXS is also a right answer for box 4 — just less popular and with no compelling advantage in 2026.
- Akita was a good answer to boxes 3 and 4 — but it's in maintenance mode since 2023, so it's the answer only for legacy code.
Trying to use one library for all four boxes — 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.
2. Mattrx — the running example
The same feature implemented every way: /campaigns — Mattrx's bulk campaign manager.
/campaigns
├── List view (filtered, paginated, sortable)
├── Search box (debounced)
├── Multi-select with bulk actions (Archive, Duplicate, Pause)
├── Detail drawer (opens for the selected row)
├── Audit log (every state change recorded with user + timestamp)
└── Optimistic mutations (archive immediately, roll back if server rejects)
State this page needs:
| State | Kind | Scope |
|---|---|---|
| Search query string | 1 (local UI) | This page |
| Selected campaign IDs | 1 or 3 (depends) | This page + bulk-actions component |
| Server list of campaigns | 2 (server cache) | This page + dashboard widget |
| Bulk-action workflow (queue, retry, audit) | 4 (app workflow) | Cross-feature (also surfaced in /inbox notifications) |
Every library below shows how it expresses these four kinds. Compare the boilerplate, the readability, the bundle, the operational cost.
3. Pure Signals — the 2026 default
3.1 The whole /campaigns page in Signals
// libs/features/campaigns/src/lib/data/campaigns.service.ts
import { inject, Injectable, signal, computed } from '@angular/core';
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs';
import { Campaign } from '@mattrx/shared/data-access/models';
@Injectable({ providedIn: 'root' })
export class CampaignsService {
private http = inject(HttpClient);
// Box 1 — local UI state (search query)
readonly query = signal('');
// Box 1 — local UI state (selection)
readonly selected = signal<Set<string>>(new Set());
// Box 2 — server cache (debounced via RxJS at the boundary, exposed as Signal)
readonly campaigns = toSignal(
toObservable(this.query).pipe(
debounceTime(200),
distinctUntilChanged(),
switchMap(q => this.http.get<Campaign[]>(`/api/campaigns?q=${encodeURIComponent(q)}`)),
),
{ initialValue: [] as Campaign[] },
);
// Derived — filtered + count
readonly filtered = computed(() => this.campaigns());
readonly selectedCount = computed(() => this.selected().size);
readonly canBulkAct = computed(() => this.selectedCount() > 0);
// Mutations — optimistic update
archive(id: string) {
const before = this.campaigns();
// optimistic remove
this.http.post(`/api/campaigns/${id}/archive`, {}).subscribe({
error: () => { /* re-fetch on error */ },
});
}
toggleSelect(id: string) {
this.selected.update(s => {
const next = new Set(s);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
}
3.2 The component
@Component({
selector: 'mx-campaigns',
standalone: true,
imports: [CampaignRow, EmptyState, BulkActions],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<input (input)="svc.query.set($any($event.target).value)" placeholder="Search…" />
@for (c of svc.filtered(); track c.id) {
<mx-campaign-row [campaign]="c"
[selected]="svc.selected().has(c.id)"
(toggle)="svc.toggleSelect(c.id)" />
} @empty {
<mx-empty-state title="No campaigns match" />
}
@if (svc.canBulkAct()) {
<mx-bulk-actions [count]="svc.selectedCount()" />
}
`,
})
export class CampaignsPage {
protected svc = inject(CampaignsService);
}
3.3 The numbers
- Total LOC: ~70 (service + component).
- Bundle cost: 0 KB (Signals are built-in).
- DevTools: none — but
console.log(svc.campaigns())is enough for 90% of debugging. - Time-travel: none. If you need it, you don't pick pure Signals for this state.
- Testing: write plain unit tests that assert
signal()values directly. NofakeAsync, nomarble testing, noMockStore.
3.4 Where pure Signals fall short
Pure Signals don't model:
- Audit logs — a record of every state change with who/when/why.
- Time-travel debugging — replay state forward and backward.
- Cross-feature side effects — when state A changes here, fire effect X over there, retried on failure.
- Strict separation of "actions" from state updates — useful for large teams to enforce who can change what.
For everything else, pure Signals are enough. In Mattrx, ~80% of state by line count is pure Signals.
4. NgRx — the canonical answer for complex workflows
NgRx has three APIs in 2026 — pick the right one. They share the same ecosystem (Redux DevTools, Effects, Selectors) but differ in size and boilerplate.
4.1 The three NgRx flavors
| Flavor | Best for | Boilerplate |
|---|---|---|
| Classic NgRx Store | Multi-feature shared state, complex actions, full Redux pattern | High (actions + reducers + selectors + effects + facade) |
| ComponentStore | Feature-local workflow that needs RxJS effects but doesn't belong in a global store | Medium |
| SignalStore (new in 2024) | The modern path — state + computed + effects in one block, with optional Redux DevTools | Low |
4.2 Classic NgRx Store — the full Redux pattern
This is the version everyone wrote in 2019. It's still the right pick for genuinely complex, cross-feature, audit-trailed state.
Actions
// libs/features/campaigns/src/lib/state/campaigns.actions.ts
import { createActionGroup, props, emptyProps } from '@ngrx/store';
export const CampaignsActions = createActionGroup({
source: 'Campaigns',
events: {
'Load': emptyProps(),
'Load Success': props<{ campaigns: Campaign[] }>(),
'Load Failure': props<{ error: string }>(),
'Search Changed': props<{ query: string }>(),
'Select Toggled': props<{ id: string }>(),
'Archive Requested': props<{ id: string }>(),
'Archive Success': props<{ id: string }>(),
'Archive Failure': props<{ id: string; error: string }>(),
},
});
Reducer
// libs/features/campaigns/src/lib/state/campaigns.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { CampaignsActions } from './campaigns.actions';
export interface CampaignsState {
campaigns: Campaign[];
selected: string[];
query: string;
loading: boolean;
error: string | null;
}
const initial: CampaignsState = {
campaigns: [], selected: [], query: '', loading: false, error: null,
};
export const campaignsReducer = createReducer(
initial,
on(CampaignsActions.load, s => ({ ...s, loading: true, error: null })),
on(CampaignsActions.loadSuccess, (s, { campaigns }) => ({ ...s, campaigns, loading: false })),
on(CampaignsActions.loadFailure, (s, { error }) => ({ ...s, error, loading: false })),
on(CampaignsActions.searchChanged, (s, { query }) => ({ ...s, query })),
on(CampaignsActions.selectToggled, (s, { id }) => ({
...s,
selected: s.selected.includes(id) ? s.selected.filter(x => x !== id) : [...s.selected, id],
})),
on(CampaignsActions.archiveRequested, (s, { id }) => ({
...s,
campaigns: s.campaigns.filter(c => c.id !== id), // optimistic remove
})),
on(CampaignsActions.archiveFailure, (s, { id, error }) => ({
...s,
// rollback would re-add the campaign from a snapshot
error,
})),
);
Selectors
// libs/features/campaigns/src/lib/state/campaigns.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { CampaignsState } from './campaigns.reducer';
const featureKey = createFeatureSelector<CampaignsState>('campaigns');
export const selectCampaigns = createSelector(featureKey, s => s.campaigns);
export const selectQuery = createSelector(featureKey, s => s.query);
export const selectFiltered = createSelector(selectCampaigns, selectQuery,
(campaigns, q) => campaigns.filter(c => c.name.toLowerCase().includes(q.toLowerCase())));
export const selectSelectedIds = createSelector(featureKey, s => s.selected);
export const selectSelectedCount = createSelector(selectSelectedIds, ids => ids.length);
export const selectCanBulkAct = createSelector(selectSelectedCount, count => count > 0);
Effects
// libs/features/campaigns/src/lib/state/campaigns.effects.ts
import { inject, Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { HttpClient } from '@angular/common/http';
import { catchError, debounceTime, map, of, switchMap } from 'rxjs';
import { CampaignsActions } from './campaigns.actions';
export const loadCampaigns$ = createEffect((actions$ = inject(Actions), http = inject(HttpClient)) =>
actions$.pipe(
ofType(CampaignsActions.load, CampaignsActions.searchChanged),
debounceTime(200),
switchMap(action => {
const q = 'query' in action ? action.query : '';
return http.get<Campaign[]>(`/api/campaigns?q=${q}`).pipe(
map(campaigns => CampaignsActions.loadSuccess({ campaigns })),
catchError(err => of(CampaignsActions.loadFailure({ error: err.message }))),
);
}),
),
{ functional: true },
);
export const archiveCampaign$ = createEffect((actions$ = inject(Actions), http = inject(HttpClient)) =>
actions$.pipe(
ofType(CampaignsActions.archiveRequested),
switchMap(({ id }) =>
http.post(`/api/campaigns/${id}/archive`, {}).pipe(
map(() => CampaignsActions.archiveSuccess({ id })),
catchError(err => of(CampaignsActions.archiveFailure({ id, error: err.message }))),
),
),
),
{ functional: true },
);
Facade (optional but encouraged)
// libs/features/campaigns/src/lib/state/campaigns.facade.ts
import { inject, Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { CampaignsActions } from './campaigns.actions';
import * as Sel from './campaigns.selectors';
@Injectable({ providedIn: 'root' })
export class CampaignsFacade {
private store = inject(Store);
campaigns = this.store.selectSignal(Sel.selectFiltered);
selectedCount = this.store.selectSignal(Sel.selectSelectedCount);
canBulkAct = this.store.selectSignal(Sel.selectCanBulkAct);
load() { this.store.dispatch(CampaignsActions.load()); }
search(query: string) { this.store.dispatch(CampaignsActions.searchChanged({ query })); }
toggle(id: string) { this.store.dispatch(CampaignsActions.selectToggled({ id })); }
archive(id: string) { this.store.dispatch(CampaignsActions.archiveRequested({ id })); }
}
Notice store.selectSignal(...) — NgRx integrates with Signals so consumers don't have to deal with Observables in templates anymore.
The component
@Component({
selector: 'mx-campaigns',
standalone: true,
template: `
<input (input)="facade.search($any($event.target).value)" />
@for (c of facade.campaigns(); track c.id) {
<mx-campaign-row [campaign]="c" (toggle)="facade.toggle(c.id)" />
}
@if (facade.canBulkAct()) {
<mx-bulk-actions [count]="facade.selectedCount()" />
}
`,
})
export class CampaignsPage {
protected facade = inject(CampaignsFacade);
}
4.3 The numbers — classic NgRx
- Total LOC: ~210 (actions 30 + reducer 60 + selectors 35 + effects 50 + facade 35).
- Bundle cost: ~25 KB gzipped (
@ngrx/store,@ngrx/effects). - DevTools: Excellent. Redux DevTools with time-travel, action replay, state snapshot.
- Testing: structured.
MockStorefor components,provideMockActionsfor effects. - Learning curve: steep. New hires take 2 weeks to ship NgRx code confidently.
4.4 NgRx SignalStore — the modern path
NgRx SignalStore replaces the Action / Reducer / Selector / Effect ceremony with a single block. It still gives you DevTools and structure, but at a fraction of the code.
// libs/features/campaigns/src/lib/state/campaigns.signal-store.ts
import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
import { withEntities, setAll, addEntity, removeEntity } from '@ngrx/signals/entities';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { pipe, debounceTime, distinctUntilChanged, switchMap, tap } from 'rxjs';
export const CampaignsStore = signalStore(
{ providedIn: 'root' },
withEntities<Campaign>(), // gives you `entities` and `entityMap` signals + helpers
withState({
query: '',
selected: new Set<string>(),
loading: false,
error: null as string | null,
}),
withComputed(state => ({
selectedCount: computed(() => state.selected().size),
canBulkAct: computed(() => state.selected().size > 0),
filtered: computed(() => {
const q = state.query().toLowerCase();
return state.entities().filter(c => c.name.toLowerCase().includes(q));
}),
})),
withMethods((store, http = inject(HttpClient)) => ({
// Debounced search → reload — `rxMethod` is the SignalStore equivalent of an Effect
search: rxMethod<string>(pipe(
tap(q => patchState(store, { query: q, loading: true })),
debounceTime(200),
distinctUntilChanged(),
switchMap(q => http.get<Campaign[]>(`/api/campaigns?q=${q}`)),
tap(campaigns => patchState(store, setAll(campaigns), { loading: false })),
)),
toggle: (id: string) => patchState(store, s => ({
selected: s.selected.has(id)
? new Set([...s.selected].filter(x => x !== id))
: new Set([...s.selected, id]),
})),
archive: rxMethod<string>(pipe(
switchMap(id =>
http.post(`/api/campaigns/${id}/archive`, {}).pipe(
tap(() => patchState(store, removeEntity(id))),
),
),
)),
})),
);
The component
@Component({
selector: 'mx-campaigns',
standalone: true,
template: `
<input (input)="store.search($any($event.target).value)" />
@for (c of store.filtered(); track c.id) {
<mx-campaign-row [campaign]="c"
[selected]="store.selected().has(c.id)"
(toggle)="store.toggle(c.id)" />
}
@if (store.canBulkAct()) {
<mx-bulk-actions [count]="store.selectedCount()" (archive)="store.archive($event)" />
}
`,
})
export class CampaignsPage {
protected store = inject(CampaignsStore);
}
4.5 The numbers — NgRx SignalStore
- Total LOC: ~75 (one file).
- Bundle cost: ~6 KB gzipped (you can drop
@ngrx/effectsif you commit to SignalStore +rxMethod). - DevTools: integrated via
withDevtools()if you want it. - Testing: trivial —
inject(CampaignsStore)in aTestBed, assert signal values. - Learning curve: gentle. If you know Signals, you can read SignalStore in 30 minutes.
4.6 ComponentStore — when feature-local NgRx is right
ComponentStore is "NgRx for a single feature, scoped to that feature's lifetime."
// libs/features/reports/src/lib/builder/report-builder.store.ts
import { ComponentStore } from '@ngrx/component-store';
import { inject, Injectable } from '@angular/core';
import { switchMap, tap } from 'rxjs';
interface BuilderState {
draft: ReportDraft;
previewLoading: boolean;
preview: ReportPreview | null;
}
@Injectable() // NOT providedIn root — scoped to the component
export class ReportBuilderStore extends ComponentStore<BuilderState> {
private http = inject(HttpClient);
constructor() {
super({ draft: emptyDraft(), previewLoading: false, preview: null });
}
readonly draft = this.selectSignal(s => s.draft);
readonly preview = this.selectSignal(s => s.preview);
readonly updateDraft = this.updater((s, patch: Partial<ReportDraft>) => ({
...s, draft: { ...s.draft, ...patch },
}));
readonly loadPreview = this.effect<ReportDraft>(draft$ =>
draft$.pipe(
tap(() => this.patchState({ previewLoading: true })),
switchMap(d => this.http.post<ReportPreview>('/api/reports/preview', d).pipe(
tap(preview => this.patchState({ preview, previewLoading: false })),
)),
),
);
}
It's provided in the component itself:
@Component({
// ...
providers: [ReportBuilderStore],
})
export class ReportBuilder { /* ... */ }
When the component goes away, the store goes away. Feature-scoped Redux, without polluting the global store.
4.7 Where each NgRx flavor fits
| Flavor | Use when |
|---|---|
| Classic | Multi-feature shared state with audit + DevTools + complex actions |
| ComponentStore | One feature's workflow, dies with the feature |
| SignalStore | New code in 2026 — start here. Promote to classic only if needed |
5. NGXS — the lower-boilerplate redux alternative
NGXS is structurally similar to NgRx classic but trades CQRS purity for decorators and class-based state.
5.1 The NGXS version
// libs/features/campaigns/src/lib/state/campaigns.state.ts
import { State, Action, Selector, StateContext } from '@ngxs/store';
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { tap } from 'rxjs';
export class LoadCampaigns { static readonly type = '[Campaigns] Load'; }
export class SearchChanged { static readonly type = '[Campaigns] Search Changed';
constructor(public query: string) {} }
export class ToggleSelected { static readonly type = '[Campaigns] Toggle Selected';
constructor(public id: string) {} }
export class ArchiveCampaign { static readonly type = '[Campaigns] Archive';
constructor(public id: string) {} }
export interface CampaignsStateModel {
campaigns: Campaign[];
selected: string[];
query: string;
}
@State<CampaignsStateModel>({
name: 'campaigns',
defaults: { campaigns: [], selected: [], query: '' },
})
@Injectable()
export class CampaignsState {
private http = inject(HttpClient);
@Selector() static query(s: CampaignsStateModel) { return s.query; }
@Selector() static selected(s: CampaignsStateModel) { return s.selected; }
@Selector() static selectedCount(s: CampaignsStateModel) { return s.selected.length; }
@Selector([CampaignsState])
static filtered(s: CampaignsStateModel): Campaign[] {
const q = s.query.toLowerCase();
return s.campaigns.filter(c => c.name.toLowerCase().includes(q));
}
@Action(LoadCampaigns)
load(ctx: StateContext<CampaignsStateModel>) {
return this.http.get<Campaign[]>(`/api/campaigns?q=${ctx.getState().query}`).pipe(
tap(campaigns => ctx.patchState({ campaigns })),
);
}
@Action(SearchChanged)
search(ctx: StateContext<CampaignsStateModel>, { query }: SearchChanged) {
ctx.patchState({ query });
return ctx.dispatch(new LoadCampaigns());
}
@Action(ToggleSelected)
toggle(ctx: StateContext<CampaignsStateModel>, { id }: ToggleSelected) {
const selected = ctx.getState().selected;
ctx.patchState({
selected: selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id],
});
}
@Action(ArchiveCampaign)
archive(ctx: StateContext<CampaignsStateModel>, { id }: ArchiveCampaign) {
return this.http.post(`/api/campaigns/${id}/archive`, {}).pipe(
tap(() => ctx.patchState({ campaigns: ctx.getState().campaigns.filter(c => c.id !== id) })),
);
}
}
@Component({
// ...
template: `
<input (input)="search($any($event.target).value)" />
@for (c of filtered(); track c.id) {
<mx-campaign-row [campaign]="c" (toggle)="toggle(c.id)" />
}
@if (selectedCount() > 0) {
<mx-bulk-actions [count]="selectedCount()" />
}
`,
})
export class CampaignsPage {
private store = inject(Store);
filtered = toSignal(this.store.select(CampaignsState.filtered), { initialValue: [] });
selectedCount = toSignal(this.store.select(CampaignsState.selectedCount), { initialValue: 0 });
search(q: string) { this.store.dispatch(new SearchChanged(q)); }
toggle(id: string) { this.store.dispatch(new ToggleSelected(id)); }
}
5.2 The numbers — NGXS
- Total LOC: ~140 (one file for state + actions).
- Bundle cost: ~14 KB gzipped.
- DevTools: yes, via the Redux DevTools plugin.
- Testing:
NgxsTestBed— competent, less ecosystem than NgRx. - Learning curve: medium — friendlier than classic NgRx for newcomers due to class-based ergonomics.
5.3 Honest verdict on NGXS
NGXS is fine. It's an objectively reasonable Redux-style library for Angular. The reason we didn't pick it at Mattrx:
- The Angular community gravitates toward NgRx — bigger ecosystem, more StackOverflow answers, more devs who've used it.
- NgRx SignalStore (2024) closes the boilerplate gap NGXS originally opened.
- We didn't find a concrete problem NGXS solved that NgRx SignalStore didn't.
If you're already on NGXS and it works, don't migrate. If you're choosing fresh in 2026, NgRx (SignalStore or classic depending on need) is the safer pick for hiring + ecosystem reasons.
6. Akita — honest reality check
Akita was a great library in 2020. It's been in maintenance mode since 2023 — the team behind it (Datorama / Salesforce) pivoted to a successor called Elf.
6.1 The Akita version (for reference + legacy code)
// libs/features/campaigns/src/lib/state/campaigns.store.ts (Akita — legacy)
import { Injectable } from '@angular/core';
import { EntityState, EntityStore, StoreConfig, QueryEntity } from '@datorama/akita';
export interface CampaignsState extends EntityState<Campaign, string> {
query: string;
selected: string[];
}
@Injectable({ providedIn: 'root' })
@StoreConfig({ name: 'campaigns' })
export class CampaignsStore extends EntityStore<CampaignsState> {
constructor() { super({ query: '', selected: [] }); }
}
@Injectable({ providedIn: 'root' })
export class CampaignsQuery extends QueryEntity<CampaignsState> {
constructor(store: CampaignsStore) { super(store); }
query$ = this.select(s => s.query);
selected$ = this.select(s => s.selected);
filtered$ = this.selectAll().pipe(
/* combineLatest with query$, filter, etc. */
);
}
// libs/features/campaigns/src/lib/data/campaigns.service.ts
@Injectable({ providedIn: 'root' })
export class CampaignsService {
constructor(private store: CampaignsStore, private http: HttpClient) {}
load(q: string) {
this.store.update({ query: q });
this.http.get<Campaign[]>(`/api/campaigns?q=${q}`)
.subscribe(campaigns => this.store.set(campaigns));
}
toggle(id: string) {
const selected = this.store.getValue().selected;
this.store.update({
selected: selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id],
});
}
}
6.2 The numbers — Akita
- Total LOC: ~100.
- Bundle cost: ~10 KB gzipped.
- DevTools: yes, separate Akita DevTools extension.
- Status: maintenance only. No new features. No major Angular alignment work.
6.3 If you're on Akita today
Two paths:
- Keep it for legacy features, migrate new features to NgRx SignalStore or pure Signals. This is what we did at Mattrx — one feature (
/inbox/archive-search) is still on Akita; the rest has moved. - Migrate to Elf if you want to stay close to the Akita mental model. Elf is the successor from the same team, actively maintained, with a Signals integration. We evaluated it; for us the choice was "stay on NgRx ecosystem" since we already had NgRx code in production.
Don't start new projects on Akita in 2026. It's a strong "in maintenance" library and the team behind it has moved on.
7. Performance and bundle comparison (numbers, not vibes)
Measured on Mattrx's CI machine (M2 Pro, Chrome 124, Angular 19, gzipped production builds).
7.1 Bundle cost added by each library
| Library | Added to bundle (gzip) | Notes |
|---|---|---|
| Pure Signals | 0 KB | Built into @angular/core |
| NgRx SignalStore | ~6 KB | @ngrx/signals only |
| NgRx ComponentStore | ~5 KB | @ngrx/component-store |
| NgRx classic Store | ~14 KB | @ngrx/store only |
| NgRx classic + Effects + Entity | ~25 KB | Full Redux pattern |
| NGXS | ~14 KB | @ngxs/store |
| Akita | ~10 KB | @datorama/akita |
7.2 Same feature, LOC compared (Mattrx /campaigns)
| Approach | LOC (state + component) | Files |
|---|---|---|
| Pure Signals | 70 | 2 |
| NgRx SignalStore | 75 | 2 |
| Akita | 100 | 4 |
| NGXS | 140 | 2 |
| NgRx classic | 210 | 5 |
The boilerplate gap closed dramatically with SignalStore. Classic NgRx is still ~3× more code than Signals, but it's the only one that gives you airtight separation between "the thing that happened" (Action) and "the resulting state change" (Reducer) — which is sometimes exactly what you want.
7.3 Re-render granularity (/campaigns search → table updates)
| Approach | Re-renders / keystroke | Reason |
|---|---|---|
BehaviorSubject + ` | async` | 1 per consumer (47 across the page) |
NgRx classic + select Observable | 1 per consumer (similar) | Observable pipeline |
NgRx classic + store.selectSignal() | ~3 | Signal granularity |
| NgRx SignalStore | ~3 | Native Signals |
| Pure Signals | ~3 | Native Signals |
| Akita query + ` | async` | 1 per consumer |
The big jump comes from moving to Signals, regardless of library. NgRx classic with store.selectSignal() performs the same as pure Signals for re-renders.
7.4 DevTools experience
| Library | Time travel | Action log | State snapshot | Verdict |
|---|---|---|---|---|
| Pure Signals | No | No | Manual | OK for small state, painful for big workflows |
| NgRx classic | Yes | Yes | Yes | Best in class |
NgRx SignalStore (withDevtools()) | Yes | Yes | Yes | Same DevTools, opt-in |
| NGXS | Yes | Yes | Yes | Solid, identical Redux DevTools |
| Akita | Yes | Yes | Yes | Separate extension, less polished |
If "what's the state of this when this bug happened?" is a question you ask weekly, DevTools is worth the bundle cost.
8. The Mattrx production layout (real, with library per feature)
Mattrx state in production (Angular 19, ~22k LOC TS):
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 workflow, dies with feature)
├── features/settings-team → Signals (simple forms)
├── features/settings-billing → NgRx classic (audit log + cross-feature notifications)
└── shared/data-access → toSignal(http.get(...)) wrappers
Lessons from this layout:
- Most features need no library — Signals are enough.
/campaignsand/settings-billing— workflow-heavy, audit-trailed → NgRx (SignalStore for the newer, classic for the older that already worked)./reports/builder— heavy local workflow that doesn't belong in a global store → ComponentStore./inbox/archive-search— Akita legacy, scheduled for migration in Q2 2026. Works fine; just paying technical debt interest.
9. Decision tree (the cheat sheet)
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(...)) for simple cases
│ NgRx Entity for collections with complex mutations
│
└── NO → Is it shared across components in one feature?
├── YES, simple → SIGNAL in a feature service
│
├── YES, complex workflow with effects → NgRx ComponentStore
│ (scoped to feature, dies with it)
│
└── NO → Is it shared ACROSS features
with audit trail / DevTools / time-travel?
├── YES, new code → NgRx SignalStore
├── YES, legacy code → NgRx classic
└── YES, on NGXS today → stay on NGXS
NO? → reconsider — you may not need a store
10. Honest stuff
- Most apps don't need a state library. They need Signals + a couple of
computed()calls. Reach for NgRx when there's actual workflow complexity, not because "real apps use Redux." - NgRx SignalStore is the modern default. If you're starting fresh in 2026 and want some structure, start there. Promote to classic NgRx only when you need full Action/Reducer separation.
- Don't pick a state library to standardize. Pick the minimum viable abstraction for each kind of state. Mattrx uses three NgRx flavors + Signals + one legacy Akita feature — and that's fine.
- Akita is in maintenance mode. Not "dead" — it works in prod and will keep doing so. But don't start new projects on it. Migrate to Signals or NgRx SignalStore for new code.
- NGXS has no killer reason to pick it in 2026. It's fine if you're on it. It's not the strongest choice if you're choosing fresh — for ecosystem reasons more than technical ones.
- Signals don't replace Redux. They replace
BehaviorSubject. The Redux pattern is still the right answer for workflow-heavy state — NgRx SignalStore just packages it in a Signals-native way. - DevTools is worth the bundle cost when you have a genuinely complex state machine. Otherwise it's overhead.
- The biggest perf wins from picking the "right" state library are downstream of Signals + OnPush. Without those, the library you pick barely matters.
11. The mental checklist
Before you commit a new piece of state to a library:
- Could a
signal()in the component handle this? (If yes — do that.) - Could a
signal()in a service handle this? (If yes — do that.) - Does this state need to survive after its host feature is destroyed?
- Will multiple features need to subscribe to changes?
- Do I need an audit log / time travel?
- Will the mutation logic be complex enough to benefit from Action/Reducer separation?
- Is the team familiar with the library I'm about to introduce?
- Does the bundle cost (vs Signals' 0 KB) actually buy me something the team will use?
If you can't answer "yes, concretely" to at least one of the bottom four, stay on Signals.
12. Closing — the right mental model
There is no "best Angular state management library" in 2026. There are four kinds of state and three good answers:
- Local / server cache / simple shared → Signals.
- Feature-local workflow → NgRx ComponentStore.
- Cross-feature workflow with DevTools → NgRx SignalStore (or classic for the heaviest cases).
Everything else is taste (and legacy decisions).
Three habits that prevent 90% of the pain in this guide:
- Default to Signals. Promote to NgRx when you have a concrete reason — usually "we need DevTools / time travel / cross-feature effects."
- Don't standardize on one library. Pick per-feature based on the kind of state. Mattrx happily ships Signals + 3 NgRx flavors + 1 legacy Akita feature. That's not a mess; it's appropriate fit.
- Treat boilerplate as a cost. SignalStore exists because the cost of classic NgRx's ceremony often exceeds its benefit. Apply that same lens to every choice.
Apply that, and the next "which state library should we use?" debate takes a 10-minute design review, not a 3-day refactor.
Further reading
- NgRx Signal Store — the modern default.
- NgRx ComponentStore — for feature-local workflows.
- NgRx Effects — when you need full classic Redux + side effects.
- NGXS docs — for the alternative.
- Elf state manager — the successor to Akita.
- Angular Signals deep dive — the pair to this guide on when to use Signals vs RxJS.
- Angular Performance Optimization — the OnPush + Signals combo that makes any state library fast.
Stuck mid-debate on "NgRx vs Signals vs something else"? Email randhir.jassal@gmail.com with the feature's actual requirements (how complex, who reads, who writes, do you need DevTools) — happy to point at the smallest viable answer.
Get the next issue
A short, curated email with the newest posts and questions.