DEV Community

kirandeepjassal-crypto
kirandeepjassal-crypto

Posted on • Originally published at prepstack.co.in

Angular State Management in 2026 — NgRx, Signals, NGXS, Akita Compared (with Bundle + LOC Numbers)

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:

  1. Local UI state — dropdown open, active tab, current input → Signal in the component
  2. Server cache — list of campaigns from the API → toSignal(http.get(...)) or NgRx Entity
  3. Shared feature state — selected rows, bulk-edit draft → Signal in a feature service
  4. 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;
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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 */ }),
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)