Back to Blog
Angular DevelopmentFebruary 23, 202611 min read

NgRx in Practice: When to Use It and When to Skip It

DR

Doug Ribeiro

11 min read

NgRx can stall a small team's sprint velocity for months. This is a failure pattern, not one team's postmortem, and it always has the same shape: a few developers, tight deadlines, and someone proposes putting every piece of state in NgRx while everyone nods. The early sprints go to writing actions, reducers, selectors, and effects for state that could have lived in two services. Then adding one field to a slice means editing the action, the state interface, the reducer, the selector and the component that reads it. Eventually someone reaches for meta-reducers to manage the complexity introduced to manage the complexity. The answer is rarely to rip NgRx out. It earns its place for the state that genuinely crosses features. What has to change is the default of putting everything in the store.

What NgRx Actually Solves

The honest pitch for NgRx is not developer experience. It is auditability and cross-feature state coordination. When you have state that is read and mutated by components in different lazy-loaded feature modules, when you need Redux DevTools time-travel for debugging production state issues, when multiple teams need explicit contracts around how state transitions happen, that is where NgRx earns its ceremony.

At BMO, those conditions were real. I worked on the bank's digital loan application platform, which is built as micro-frontends. NgRx was already in place when I joined, and the architecture decisions belong to a separate team. Having worked inside it, I think it is the right call for that kind of product: in a large financial application, a service with a BehaviorSubject would become impossible to debug inside six months. NgRx makes the transitions explicit and traceable.

The Classic Pattern, No Sugar-Coating

Before reaching for Signal Store or any lighter alternative, understand what you are deciding against. The classic Actions, Reducers, Selectors, Effects pattern is verbose by design. The verbosity is the documentation. Here is an illustrative slice for a generic loan application submission, written for this post rather than taken from any real codebase:

typescript
// actions/application.actions.ts
export const submitApplication = createAction('[Application] Submit');
export const submitApplicationSuccess = createAction(
  '[Application] Submit Success',
  props<{ applicationId: string; offerAmount: number }>()
);
export const submitApplicationFailure = createAction(
  '[Application] Submit Failure',
  props<{ error: string; retryable: boolean }>()
);

// reducers/application.reducer.ts
export interface ApplicationState {
  applicationId: string | null;
  offerAmount: number | null;
  submitting: boolean;
  error: string | null;
  retryable: boolean;
}

const initialState: ApplicationState = {
  applicationId: null,
  offerAmount: null,
  submitting: false,
  error: null,
  retryable: false
};

export const applicationReducer = createReducer(
  initialState,
  on(submitApplication, state => ({ ...state, submitting: true, error: null })),
  on(submitApplicationSuccess, (state, { applicationId, offerAmount }) => ({
    ...state, applicationId, offerAmount, submitting: false
  })),
  on(submitApplicationFailure, (state, { error, retryable }) => ({
    ...state, error, retryable, submitting: false
  }))
);
typescript
// effects/application.effects.ts
@Injectable()
export class ApplicationEffects {
  private actions$ = inject(Actions);
  private applicationService = inject(ApplicationService);

  submit$ = createEffect(() =>
    this.actions$.pipe(
      ofType(submitApplication),
      exhaustMap(() =>
        this.applicationService.submit().pipe(
          map(result => submitApplicationSuccess(result)),
          catchError(err => of(submitApplicationFailure({
            error: err.message,
            retryable: err.status >= 500
          })))
        )
      )
    )
  );
}

I use exhaustMap here, not switchMap. On a loan application submission, you do not cancel in-flight requests when the user double-clicks. That is the kind of decision that becomes explicit with Effects and invisible with a service method.

When to Skip the Classic Pattern

  • State that is local to one feature and never read outside it
  • Simple async data loading with no cross-feature dependencies
  • Solo or small-team projects where the Redux DevTools audit trail adds zero value
  • Prototype and MVP phases where you cannot afford the boilerplate cost
  • State that changes in response to user input inside a single form or wizard step

For all of these, a service with signals is the right call today. On Angular versions before 16, where signals are not an option, the same move works with plain services and BehaviorSubjects: pull wizard-style state out of the store and keep NgRx only for the state that genuinely crosses features. Hundreds of lines of actions, reducers, and selectors can collapse into a couple of small services that are simpler to test, simpler to debug, and faster to change.

NgRx Signal Store: The Middle Ground

NgRx Signal Store shipped in NgRx v17 as a developer preview and became production-ready in NgRx v18. Note that those are NgRx versions, not Angular ones. It is genuinely good. It gives you the structured, explicit state management of NgRx with signals instead of RxJS observables, and without the Actions, Reducers, Effects ceremony for cases that do not need it.

typescript
import { computed, inject } from '@angular/core';
import { tapResponse } from '@ngrx/operators';
import { signalStore, withState, withMethods, withComputed, patchState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { pipe, switchMap, tap } from 'rxjs';
import { DocumentService, UploadedDocument } from './document.service';

interface DocumentUploadState {
  applicationId: string | null;
  documents: UploadedDocument[];
  uploading: boolean;
  error: string | null;
}

export const DocumentUploadStore = signalStore(
  { providedIn: 'root' },
  withState<DocumentUploadState>({
    applicationId: null,
    documents: [],
    uploading: false,
    error: null
  }),
  withComputed(({ documents }) => ({
    documentCount: computed(() => documents().length),
    hasRequiredDocuments: computed(() => documents().length >= 2)
  })),
  withMethods((store, service = inject(DocumentService)) => ({
    uploadDocument: rxMethod<File>(pipe(
      tap(() => patchState(store, { uploading: true })),
      switchMap(file =>
        service.upload(file).pipe(
          tapResponse(
            doc => patchState(store, { documents: [...store.documents(), doc], uploading: false }),
            err => patchState(store, { error: err.message, uploading: false })
          )
        )
      )
    ))
  }))
);

tapResponse moved out of @ngrx/component-store and into @ngrx/operators in NgRx 18, which is the import you want on any current version. I use Signal Store for feature-level state that is too complex for a raw service but does not need Redux DevTools auditing. The threshold is roughly: multiple async operations with interdependent loading states, or state shared across three or more components within the same feature.

The Decision I Actually Use

  • Local component state: signals directly in the component
  • Feature-level state with moderate complexity: NgRx Signal Store
  • Global state with cross-feature reads, complex async orchestration, or audit requirements: classic NgRx
  • If you inherited classic NgRx: keep it, do not rewrite it, the patterns are sound
Use NgRx WhenState shared across 3+ featuresNeed Redux DevTools for debuggi…Complex async side effects (Eff…Large team with strict state co…Skip NgRx WhenState is local to one featureSimple component-level reactivi…Small/medium app with clear own…Team prefers less boilerplateVS

NgRx does not make your application more professional. It makes state transitions explicit. That is only valuable if you were losing track of them.

The mistake I see most often is teams reaching for NgRx because it signals seriousness, not because it solves a problem they have. If you have never shipped a bug caused by unpredictable shared state across features, you probably do not need classic NgRx yet. Build with signals and Signal Store. Add the full Redux machinery when the complexity actually demands it.

References

AngularNgRxState ManagementTypeScript