When Angular Signals hit stable in v17, the immediate reaction in the community was to frame it as a replacement for NgRx. I understand why. Signals are built in, they are simpler, and they cover a lot of ground. But I ran NgRx on a credit card platform with 50,000+ users, and I would not rip a store like that out just because signals exist. The two tools solve different problems.
What Signals Are Good At
Signals are a synchronous reactive primitive. A signal holds a value. Anything that reads that value in a reactive context, a template, a computed(), or an effect(), tracks the dependency automatically and updates when the signal changes. No pipes, no subscriptions, no async.
import { signal, computed } from '@angular/core';
import { Account } from './accounts.model';
const selectedAccountId = signal<string | null>(null);
const accounts = signal<Account[]>([]);
const selectedAccount = computed(() =>
accounts().find(a => a.id === selectedAccountId())
);
// In template: {{ selectedAccount()?.balance | currency }}
// Updates instantly when either signal changesFor feature-scoped state, signals are dramatically less ceremony than NgRx. A signal-based service for a feature replaces a reducer, a set of selectors, and the associated boilerplate with about thirty lines of TypeScript. Here is an illustrative one for a loan application feature.
import { DestroyRef, Injectable, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { LendingApiService, LoanApplication } from './lending-api.service';
@Injectable({ providedIn: 'root' })
export class LendingStateService {
private api = inject(LendingApiService);
private destroyRef = inject(DestroyRef);
private _applications = signal<LoanApplication[]>([]);
private _loading = signal(false);
private _error = signal<string | null>(null);
readonly applications = this._applications.asReadonly();
readonly loading = this._loading.asReadonly();
readonly error = this._error.asReadonly();
readonly pendingApplications = computed(() =>
this._applications().filter(a => a.status === 'pending')
);
loadApplications(): void {
this._loading.set(true);
this.api.getApplications()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: apps => {
this._applications.set(apps);
this._loading.set(false);
},
error: err => {
this._error.set(err.message);
this._loading.set(false);
}
});
}
}loadApplications runs outside an injection context, so takeUntilDestroyed needs the DestroyRef passed in explicitly. Without it the subscription outlives the service and writes into signals nobody reads any more.
Where Signals Start Showing Cracks
Signals have no action log. You cannot replay state transitions. You cannot open Redux DevTools and see what happened between the user clicking 'Submit' and the error state appearing. In a regulated environment like a bank, that auditability matters for debugging production incidents.
Signals also have no built-in pattern for complex async side effects. Loading data on route init, retrying on failure, showing an optimistic update while the real response is in flight, then rolling back on error: this is where NgRx Effects with its standardized action-effect-reducer cycle earns its complexity cost.
What NgRx Is Actually Good At
NgRx is not just a state container. It is an audit log. Every state change is a dispatched action with a name and a payload. When an application submits and the user ends up in an error state, you can open Redux DevTools and replay exactly what happened: LoadApplications dispatched, LoadApplicationsSuccess received, SubmitApplication dispatched, SubmitApplicationFailure received with the server error. That replay capability is where the store pays for itself in debugging time.
// lending.actions.ts
import { createAction, props } from '@ngrx/store';
import { Confirmation } from './lending-api.service';
export const submitApplication = createAction(
'[Lending] Submit Application',
props<{ applicationId: string }>()
);
export const submitApplicationSuccess = createAction(
'[Lending] Submit Application Success',
props<{ confirmation: Confirmation }>()
);
export const submitApplicationFailure = createAction(
'[Lending] Submit Application Failure',
props<{ error: string }>()
);
// lending.effects.ts
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, exhaustMap, map, of } from 'rxjs';
import { LendingApiService } from './lending-api.service';
@Injectable()
export class LendingEffects {
private actions$ = inject(Actions);
private lendingApi = inject(LendingApiService);
submitApplication$ = createEffect(() =>
this.actions$.pipe(
ofType(submitApplication),
exhaustMap(({ applicationId }) =>
this.lendingApi.submit(applicationId).pipe(
map(confirmation => submitApplicationSuccess({ confirmation })),
catchError(err => of(submitApplicationFailure({ error: err.message })))
)
)
)
);
}How I Would Split It in a Large App
Application shell state belongs in the NgRx store: authentication, permissions, global notifications, cross-feature data. In a large app split into lazy-loaded features or micro-frontends, these are shared across boundaries and need to survive route changes. The audit trail matters here.
Feature state belongs in signal-based services shaped like the LendingStateService above: signals for local data, a read from NgRx for the user's session context, and no NgRx actions for purely local UI state like which step is active or whether a panel is expanded.
Bridging NgRx and Signals with toSignal
toSignal from @angular/core/rxjs-interop converts any observable to a signal. This is the bridge between the NgRx store and signal-based templates.
import { toSignal } from '@angular/core/rxjs-interop';
import { inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { selectUserPermissions } from './auth.selectors';
@Component({
standalone: true,
template: `
@if (permissions()?.canApply) {
<button (click)="apply()">Apply Now</button>
}
`
})
export class LendingComponent {
private store = inject(Store);
// NgRx selector consumed as a signal. No async pipe, no subscription.
permissions = toSignal(this.store.select(selectUserPermissions));
}NgRx SignalStore: The Best of Both
NgRx SignalStore uses signals internally but adds the reducer-style immutability and the rxMethod pattern for handling async. If you want signal ergonomics with the structured approach of NgRx, this is worth evaluating. On an established platform running the classic NgRx store, a store migration is an architecture decision, not a feature developer's call. For new projects, I would start here instead of choosing between the two. I walk through a SignalStore with withState, withComputed and an rxMethod for the async work in my post on NgRx in practice, so I am not repeating the code here.
- New project, small team, feature-scoped state: start with signal services
- New project, regulated industry, large team: NgRx or NgRx SignalStore
- Existing NgRx project: use toSignal to bridge selectors to templates, add signal services for new features
- The thing that should never live in signals: state that multiple lazy-loaded features read and write concurrently
Signals reduce ceremony. NgRx enforces discipline. The best setup uses both: signals for feature state, NgRx for application-wide concerns.
References
- Angular Signals overview. https://angular.dev/guide/signals
- toSignal API reference. https://angular.dev/api/core/rxjs-interop/toSignal
- NgRx official documentation. https://ngrx.io/docs
- NgRx SignalStore guide. https://ngrx.io/guide/signals/signal-store