Back to Blog
Angular DevelopmentDecember 21, 20259 min read

Angular Signals vs NgRx: Which Should You Use?

DR

Doug Ribeiro

9 min read

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.

SignalsFeature-scoped stateMinimal boilerplateSynchronous updatesNo DevTools neededSignal services (~30 lines)NgRxApplication-wide stateRedux DevTools audit trailComplex async orchestrationExplicit action contractsCross-feature coordinationVS

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.

typescript
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 changes

For 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.

typescript
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.

typescript
// 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.

typescript
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.

ComponentReads signals directly, no subscription boilerplate 1 Signal Service (Feature State)toSignal() bridges NgRx selectors into signals 2 NgRx Store (App-Wide State)Actions, Reducers, Selectors, Effects 3 HTTP / External APIsSide effects managed by NgRx Effects 4

References

AngularSignalsNgRxState ManagementTypeScript