Angular State Management: Choosing Between Signals, RxJS Services, and NgRx SignalStore
Signals, RxJS services, or NgRx SignalStore? A constraint-first comparison for Angular 17+, with a working cart store example and a bundle-and-profile verification checklist.
08 Apr 2026, 14:44 UTC

The decision is not "which library is best." It is: where does shared state live, and what has to cross an asynchronous boundary to reach it? Most Angular teams get this wrong by picking a tool before listing constraints, then discovering that HTTP streams, router events, and WebSocket messages are Observables while everything else is now a signal.
The short answer that holds up in most Angular 17+ codebases: keep synchronous, feature-scoped state in plain signals inside a service; keep Observables at the edges (HTTP, router, sockets) and convert with toSignal(); reach for NgRx SignalStore only when you need cross-feature coordination, transactions, or time-travel debugging badly enough to pay for the abstraction.
What actually constrains the choice
- Team familiarity. A team fluent in RxJS operators will move faster with Observable services than with a store they have to learn. A team that finds marble diagrams painful will move faster with signals.
- Async boundaries.
HttpClient,Routerevents, and most third-party SDKs emit Observables. Signals do not replace them; they sit downstream of them. - Debugging needs. Redux-style devtools (action log, state snapshots) come from NgRx, not from plain signals.
- Migration surface. Wrapping an existing Observable service with
toSignal()is a smaller change than rewriting it into a store. - Bundle budget. Published size deltas for store libraries are widely quoted but vary by version and tree-shaking. Measure your own build rather than trusting a number from a blog post.
Three supported options at a glance
| Option | What you write | Extra dependency | Fits when | Main cost |
|---|---|---|---|---|
| Plain signals in a service | signal(), computed(), effect() held in an @Injectable |
None beyond @angular/core |
Feature-scoped state, derived values, small teams | No devtools, no transaction semantics; you own every async boundary |
RxJS service (BehaviorSubject / Observable) |
Subject plus operators, consumed via async pipe or subscription |
RxJS (usually already present) | Existing Observable-heavy code, complex stream composition, debounce/retry logic | Manual subscription lifecycle; change detection is triggered per emitting component |
| NgRx SignalStore | signalStore() with withState, withComputed, withMethods, withHooks |
@ngrx/signals (plus RxJS for effects) |
Large apps needing shared state, memoized selectors, devtools, testable store units | Extra dependency and a second mental model on top of signals |
Trade-offs that decide the choice
Async boundaries are the real dividing line
Signals are synchronous and pull-based: reading a signal returns its current value. Observables are push-based and can represent a stream that has not emitted yet, errors, or completes. Converting an HTTP call to a signal loses the "no value yet" state unless you supply initialValue or handle undefined. That is a design decision, not a bug — but it means signals are a poor fit for anything that genuinely streams, such as a live price feed or a search-as-you-type pipeline with debounceTime and switchMap.
The practical rule: compose streams with RxJS, then convert once at the component or store boundary.
Change detection and derived state
A computed() memoizes its result and only recomputes when a dependency changes. That is a real advantage over recomputing derived values inside a template getter or an async pipe chain, where the work repeats on each change detection pass. Signals also let components avoid whole-tree checks when combined with OnPush.
Two rules that are easy to violate: computed() must be pure — it runs during change detection and must not write state or call an API. And effect() must be created in an injection context (a constructor, field initializer, or explicit injector argument); calling it lazily inside a click handler throws.
Debugging and ramp-up
Plain signals give you no action log. If a bug report says "the total was wrong after three rapid clicks," you reconstruct the sequence by hand. NgRx SignalStore's methods and hooks give you named, testable units and devtools integration. If your app has one feature with shared state, that trade is not worth it. If it has fifteen features mutating a shared entity graph, it usually is.
A concrete implementation: signals with one RxJS edge
Run these commands from your project root. The example assumes a standalone Angular 17+ app; no NgModule changes are required.
// src/app/cart/cart.store.ts
import { Injectable, signal, computed } from '@angular/core';
export interface CartLine { sku: string; qty: number; unitPrice: number; }
@Injectable({ providedIn: 'root' })
export class CartStore {
private readonly lines = signal<CartLine[]>([]);
readonly count = computed(() =>
this.lines().reduce((n, l) => n + l.qty, 0));
readonly subtotal = computed(() =>
this.lines().reduce((n, l) => n + l.qty * l.unitPrice, 0));
add(line: CartLine): void {
this.lines.update(prev => {
const i = prev.findIndex(l => l.sku === line.sku);
if (i === -1) return [...prev, line];
const next = [...prev];
next[i] = { ...next[i], qty: next[i].qty + line.qty };
return next;
});
}
}
Now the async edge. toSignal() must be called in an injection context — a field initializer or constructor — not inside a method:
// src/app/cart/shipping.component.ts (excerpt)
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
import { CartStore } from './cart.store';
@Component({ selector: 'app-shipping', standalone: true, template: '...' })
export class ShippingComponent {
private readonly http = inject(HttpClient);
readonly cart = inject(CartStore);
// initialValue keeps the template free of undefined checks.
readonly shipping = toSignal(
this.http.get<number>('/api/shipping-estimate'),
{ initialValue: 0 }
);
}
If you are migrating an existing Observable service, this is the whole change: leave the service as-is, add a toSignal() wrapper in the consuming component, and delete the wrapper to revert. No store rewrite, no rollback plan needed.
How to verify the choice in your own app
- Check the version you actually have. Run
ng versionand confirm the Angular and@ngrx/signalsversions. Signal inputs,model(), and the zoneless change detection provider have all changed name or stability status across releases — verify against the changelog for your installed version before copying any snippet. - Measure the bundle, do not estimate it. Run
ng build --stats-jsonand inspect the output with a bundle analyzer. Compare the same feature implemented two ways rather than comparing against a number from an article. - Profile one interaction. Use the browser Performance panel while clicking through the feature. Angular's development builds also expose a debug profiler helper on the
ngglobal; treat its output as a rough signal only, since it is a dev-mode utility and not a stable API. - Test the store synchronously. A signal-based service can be asserted directly after calling a method, with no
TestScheduleror marble setup. If your tests need marble diagrams for state that never crosses an async boundary, that is a sign the pattern is heavier than the problem.
Limitations and what to re-check
This guide assumes Angular 17 or later and does not cover zoneless change detection, which was still experimental in that line and can break third-party libraries that assume Zone.js. It also does not cover server-side rendering hydration, where signal state must be transferred carefully.
Version-specific details — the exact stability status of signal inputs, the current name of the zoneless provider, and NgRx SignalStore's peer dependency range — should be confirmed against the changelogs and npm view @ngrx/signals peerDependencies for the versions in your lockfile. Treat anything in this article that names an API as a starting point for verification, not as a guarantee.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.