NgRx: When to Use It, How to Use It

Introduction

The first time I introduced NgRx to a team, half of them thought I was overcomplicating things. The second time I introduced it to a different team, half of them thought it was the correct solution for everything — every form, every API call, every dropdown toggle.

Both reactions are wrong, and understanding why they’re wrong is actually the best starting point for understanding NgRx itself.

NgRx is not a silver bullet for state management. It is a specific solution to a specific class of problem: shared state that multiple parts of your application read and modify, that needs to be predictable, traceable, and testable. When that class of problem exists, NgRx is excellent. When it doesn’t, NgRx is overhead — real, significant overhead that you will feel every time you add a feature.

Why NgRx Exists: The Problem First

Before any API, you need to understand the problem NgRx solves. If you don’t feel the problem first, the solution looks like bureaucracy.

Imagine an Angular application — where multiple components need to know the current list of support tickets. The ticket list is shown in a dashboard. A notification badge shows the count of open tickets. A sidebar shows the most recent ticket. A modal allows creating a new ticket and immediately adds it to the list.

Without shared state management, you have a few options:

Option 1: Each component fetches its own data. The dashboard calls the API. The badge calls the API. The sidebar calls the API. Three HTTP requests for the same data. When a new ticket is created, all three need to know — how do you tell them?

Option 2: A shared service holds the state. You put the ticket list in an Injectable service. Components inject it and read from it. When a ticket is created, the service updates its internal array. But now: who triggers the re-render? How do you handle loading state? Error state? What if two components update the service simultaneously? How do you debug why the state is wrong — there’s no history, no log of what happened?

Option 3: Pass data through inputs and outputs. Works for simple parent-child relationships. Falls apart when the components sharing state are at different levels of the tree, or aren’t related at all.

NgRx solves this with a unidirectional data flow model: components dispatch actions, reducers compute new state from those actions, selectors expose that state to components, and effects handle side effects like HTTP calls. The state is centralised, immutable, and every change is a traceable action. When something is wrong, you know exactly what happened and when.

That predictability is the product NgRx is selling. It comes at a cost in boilerplate and indirection. The question is always whether the problem is complex enough to justify the cost.

When to Use NgRx and When Not To

This is the question I spend the most time while learning and teaching NgRx, because getting this wrong in either direction is expensive.

Use NgRx when:

  • Multiple unrelated components share state that needs to stay in sync
  • You need optimistic updates with rollback on error
  • You need to track state history or replay actions (time-travel debugging)
  • The application state has complex interdependencies — changing one thing should reliably trigger updates elsewhere
  • You need server state to be cached globally and invalidated consistently
  • The team is large enough that predictable state changes and a clear audit trail genuinely help

Don’t use NgRx when:

  • State is local to a single component or a small component subtree — use component state or a component-scoped service
  • You’re building a simple CRUD form — use ReactiveForms and a service
  • The application is small enough that a service with Observables or signals solves it cleanly
  • The team is small and the overhead of actions/reducers for every operation is slowing delivery without providing proportionate value

Installation and Setup

ng add @ngrx/store @ngrx/effects @ngrx/entity @ngrx/component-store @ngrx/signals

Or with npm, installing each package explicitly:

npm install @ngrx/store @ngrx/effects @ngrx/entity @ngrx/store-devtools
npm install @ngrx/component-store @ngrx/signals

For a standalone Angular application:

// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { AppComponent } from './app/app.component';
import { ticketReducer } from './app/tickets/store/ticket.reducer';
import { TicketEffects } from './app/tickets/store/ticket.effects';

bootstrapApplication(AppComponent, {
  providers: [
    provideStore({
      tickets: ticketReducer,
    }),
    provideEffects([TicketEffects]),
    provideStoreDevtools({
      maxAge: 25,
      logOnly: !isDevMode(),
      autoPause: true,
      trace: false,
    }),
  ],
});

Install the Redux DevTools browser extension. This is not optional. The DevTools are the primary reason NgRx is worth its overhead on large applications — you can see every action dispatched, the state before and after each action, replay sequences of actions, and time-travel through the state history. Without them, NgRx is difficult to debug. With them, it’s one of the most debuggable frontend architectures available.

Actions: The Language of State Changes

Actions are plain objects that describe what happened. They are the vocabulary of your application’s state changes. Every state transition starts with an action.

The old way (pre-v8) — for context

// NgRx v5-v7: string constants and action classes
export const LOAD_TICKETS = '[Tickets] Load Tickets';
export const LOAD_TICKETS_SUCCESS = '[Tickets] Load Tickets Success';
export const LOAD_TICKETS_FAILURE = '[Tickets] Load Tickets Failure';

export class LoadTickets implements Action {
  readonly type = LOAD_TICKETS;
}

export class LoadTicketsSuccess implements Action {
  readonly type = LOAD_TICKETS_SUCCESS;
  constructor(public payload: { tickets: Ticket[] }) {}
}

export type TicketActions = LoadTickets | LoadTicketsSuccess;

This worked but required significant boilerplate and the union type management was tedious. Understanding this helps when you’re working on a codebase that hasn’t been migrated — they still exist in production.

The modern way — createAction

// tickets.actions.ts
import {
  createAction,
  createActionGroup,
  emptyProps,
  props,
} from '@ngrx/store';

// Individual actions — fine for small feature sets
export const loadTickets = createAction('[Tickets] Load Tickets');

export const loadTicketsSuccess = createAction(
  '[Tickets] Load Tickets Success',
  props<{ tickets: Ticket[] }>()
);

export const loadTicketsFailure = createAction(
  '[Tickets] Load Tickets Failure',
  props<{ error: string }>()
);

createActionGroup — the modern preferred approach

createActionGroup (v14+) groups related actions and removes repetitive source prefixes:

import { createActionGroup, emptyProps, props } from '@ngrx/store';

export const TicketActions = createActionGroup({
  source: 'Tickets',
  events: {
    // emptyProps for actions with no payload
    'Load Tickets': emptyProps(),
    'Load Tickets Success': props<{ tickets: Ticket[] }>(),
    'Load Tickets Failure': props<{ error: string }>(),

    'Load Ticket': props<{ id: string }>(),
    'Load Ticket Success': props<{ ticket: Ticket }>(),
    'Load Ticket Failure': props<{ error: string }>(),

    'Create Ticket': props<{ data: CreateTicketDto }>(),
    'Create Ticket Success': props<{ ticket: Ticket }>(),
    'Create Ticket Failure': props<{ error: string }>(),

    'Update Ticket Status': props<{ id: string; status: TicketStatus }>(),
    'Update Ticket Status Success': props<{ ticket: Ticket }>(),
    'Update Ticket Status Failure': props<{ id: string; error: string }>(),

    'Select Ticket': props<{ id: string | null }>(),
  },
});

Angular generates the action creators automatically with correctly typed names:

// What gets generated:
TicketActions.loadTickets();
TicketActions.loadTicketsSuccess({ tickets });
TicketActions.loadTicketsFailure({ error: 'Failed to load' });
TicketActions.selectTicket({ id: '123' });

The naming convention matters. I use [Source] Event — the source is the feature or context, the event is what happened in past tense. [Tickets] Load Tickets not [Tickets] Fetch Tickets or [Tickets] Get Tickets. Consistency here makes the DevTools readable at a glance.

Reducers: Pure Functions That Compute State

A reducer takes the current state and an action, and returns a new state. It must be a pure function — no side effects, no API calls, no mutations. Same input always produces same output.

State shape and initial state

// ticket.state.ts
import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';

export interface Ticket {
  id: string;
  accountId: string;
  subject: string;
  status: 'open' | 'in_progress' | 'resolved' | 'closed';
  priority: 'low' | 'medium' | 'high' | 'critical';
  createdAt: string;
  updatedAt: string;
}

// EntityState adds ids: string[] and entities: Dictionary<Ticket>
export interface TicketState extends EntityState<Ticket> {
  selectedId: string | null;
  loading: boolean;
  error: string | null;
}

export const ticketAdapter: EntityAdapter<Ticket> = createEntityAdapter<Ticket>(
  {
    selectId: (ticket) => ticket.id,
    sortComparer: (a, b) =>
      new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
  }
);

export const initialState: TicketState = ticketAdapter.getInitialState({
  selectedId: null,
  loading: false,
  error: null,
});

The reducer

// ticket.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { TicketActions } from './ticket.actions';
import { ticketAdapter, initialState } from './ticket.state';

export const ticketReducer = createReducer(
  initialState,

  // Load collection
  on(TicketActions.loadTickets, (state) => ({
    ...state,
    loading: true,
    error: null,
  })),

  on(TicketActions.loadTicketsSuccess, (state, { tickets }) =>
    ticketAdapter.setAll(tickets, {
      ...state,
      loading: false,
      error: null,
    })
  ),

  on(TicketActions.loadTicketsFailure, (state, { error }) => ({
    ...state,
    loading: false,
    error,
  })),

  // Load single
  on(TicketActions.loadTicketSuccess, (state, { ticket }) =>
    ticketAdapter.upsertOne(ticket, state)
  ),

  // Create
  on(TicketActions.createTicketSuccess, (state, { ticket }) =>
    ticketAdapter.addOne(ticket, state)
  ),

  // Update
  on(TicketActions.updateTicketStatusSuccess, (state, { ticket }) =>
    ticketAdapter.updateOne({ id: ticket.id, changes: ticket }, state)
  ),

  // Optimistic update — update immediately, may be rolled back
  on(TicketActions.updateTicketStatus, (state, { id, status }) =>
    ticketAdapter.updateOne({ id, changes: { status } }, state)
  ),

  // Rollback on failure — restore previous status from server response
  on(TicketActions.updateTicketStatusFailure, (state, { id, error }) => ({
    ...ticketAdapter.updateOne(
      // We'd need the previous state here — covered in the advanced section
      { id, changes: {} },
      state
    ),
    error,
  })),

  // Selection
  on(TicketActions.selectTicket, (state, { id }) => ({
    ...state,
    selectedId: id,
  }))
);

The golden rule of reducers: never mutate state. Always return a new object reference. NgRx uses reference equality to detect state changes — if you mutate the existing object, change detection won’t fire and components won’t update.

NgRx v16+ uses Immer under the hood via createReducer if you enable it, but by default you need spread operators and the entity adapter methods, which handle immutability for you.

Entity Adapter: Managing Collections Without the Pain

@ngrx/entity is one of the most underused and most valuable parts of NgRx. It manages normalised state for collections — instead of storing an array of objects, you store a dictionary keyed by ID and a separate array of IDs. This makes lookups, updates, and deletions O(1) instead of O(n).

Entity adapter methods

const adapter = createEntityAdapter<Ticket>();

// These are all the methods — each returns a new state
adapter.addOne(ticket, state); // Add one entity
adapter.addMany(tickets, state); // Add many entities
adapter.setOne(ticket, state); // Add or replace one entity
adapter.setMany(tickets, state); // Add or replace many entities
adapter.setAll(tickets, state); // Replace entire collection
adapter.removeOne(id, state); // Remove by id
adapter.removeMany(ids, state); // Remove many by ids
adapter.removeAll(state); // Remove all
adapter.updateOne({ id, changes }, state); // Partial update by id
adapter.updateMany(updates, state); // Partial update many
adapter.upsertOne(ticket, state); // Insert or update
adapter.upsertMany(tickets, state); // Insert or update many
adapter.mapOne({ id, map }, state); // Transform one entity with a function
adapter.map(mapFn, state); // Transform all entities with a function

The map methods are powerful and rarely documented well. adapter.map applies a transformation to every entity in the collection without you needing to write the loop:

// Mark all tickets as read
on(TicketActions.markAllAsRead, (state) =>
  ticketAdapter.map((ticket) => ({ ...ticket, read: true }), state)
);

The normalised state shape

What EntityState gives you in the store:

{
  "tickets": {
    "ids": ["t-001", "t-002", "t-003"],
    "entities": {
      "t-001": { "id": "t-001", "subject": "Login issue", "status": "open" },
      "t-002": {
        "id": "t-002",
        "subject": "Export broken",
        "status": "resolved"
      },
      "t-003": {
        "id": "t-003",
        "subject": "Slow load",
        "status": "in_progress"
      }
    },
    "selectedId": "t-001",
    "loading": false,
    "error": null
  }
}

The ids array preserves order. The entities dictionary enables O(1) lookups. The selectors that come with the adapter (covered next) handle all the recombination you need.

Selectors: Deriving Data from State

Selectors are pure functions that derive data from the store. They are memoised — if the input state slice hasn’t changed, the selector returns the cached result without recomputing. This is how NgRx avoids unnecessary re-renders.

Feature selector and entity selectors

// ticket.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { TicketState, ticketAdapter } from './ticket.state';

// Step 1: Select the feature slice
const selectTicketState = createFeatureSelector<TicketState>('tickets');

// Step 2: Get the entity adapter's built-in selectors
const { selectIds, selectEntities, selectAll, selectTotal } =
  ticketAdapter.getSelectors();

// Step 3: Compose selectors
export const selectAllTickets = createSelector(selectTicketState, selectAll);

export const selectTicketEntities = createSelector(
  selectTicketState,
  selectEntities
);

export const selectTicketCount = createSelector(selectTicketState, selectTotal);

export const selectTicketsLoading = createSelector(
  selectTicketState,
  (state) => state.loading
);

export const selectTicketsError = createSelector(
  selectTicketState,
  (state) => state.error
);

export const selectSelectedTicketId = createSelector(
  selectTicketState,
  (state) => state.selectedId
);

export const selectSelectedTicket = createSelector(
  selectTicketEntities,
  selectSelectedTicketId,
  (entities, selectedId) => (selectedId ? entities[selectedId] : null)
);

// Derived selectors — computed from other selectors
export const selectOpenTickets = createSelector(selectAllTickets, (tickets) =>
  tickets.filter((t) => t.status === 'open')
);

export const selectOpenTicketCount = createSelector(
  selectOpenTickets,
  (tickets) => tickets.length
);

export const selectCriticalOpenTickets = createSelector(
  selectOpenTickets,
  (tickets) => tickets.filter((t) => t.priority === 'critical')
);

export const selectTicketById = (id: string) =>
  createSelector(selectTicketEntities, (entities) => entities[id] ?? null);

Selectors with props — the factory pattern

The selectTicketById above is a selector factory — a function that takes a parameter and returns a selector. This is the right pattern for selecting a specific entity by a runtime value:

// In the component
@Component({ ... })
export class TicketDetailComponent {
  private store = inject(Store);
  private route = inject(ActivatedRoute);

  ticket$ = this.route.paramMap.pipe(
    map(params => params.get('id')!),
    switchMap(id => this.store.select(selectTicketById(id)))
  );
}

Combining selectors across feature slices

This is where selectors pay for themselves on complex applications:

// Combining tickets with account data from a different feature slice
export const selectTicketsWithAccountInfo = createSelector(
  selectAllTickets,
  selectAccountEntities, // from account.selectors.ts
  (tickets, accounts) =>
    tickets.map((ticket) => ({
      ...ticket,
      account: accounts[ticket.accountId],
    }))
);

One selector, two slices, fully memoised. The component gets a clean combined view without knowing anything about how the store is structured.

Effects: Managing Side Effects

Effects are where NgRx handles the world outside the store — HTTP calls, localStorage, WebSocket messages, routing. They listen for actions, do something asynchronous, and dispatch new actions with the result.

The complete effects pattern

// ticket.effects.ts
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import {
  catchError,
  map,
  switchMap,
  mergeMap,
  exhaustMap,
  withLatestFrom,
  tap,
} from 'rxjs/operators';
import { of } from 'rxjs';
import { Router } from '@angular/router';
import { TicketActions } from './ticket.actions';
import { TicketService } from '../services/ticket.service';
import { selectSelectedTicketId } from './ticket.selectors';

@Injectable()
export class TicketEffects {
  private actions$ = inject(Actions);
  private ticketService = inject(TicketService);
  private store = inject(Store);
  private router = inject(Router);

  // Load collection — switchMap cancels previous in-flight request
  loadTickets$ = createEffect(() =>
    this.actions$.pipe(
      ofType(TicketActions.loadTickets),
      switchMap(() =>
        this.ticketService.getTickets().pipe(
          map((tickets) => TicketActions.loadTicketsSuccess({ tickets })),
          catchError((error) =>
            of(TicketActions.loadTicketsFailure({ error: error.message }))
          )
        )
      )
    )
  );

  // Load single ticket
  loadTicket$ = createEffect(() =>
    this.actions$.pipe(
      ofType(TicketActions.loadTicket),
      mergeMap(
        (
          { id } // mergeMap: allow parallel loads for different IDs
        ) =>
          this.ticketService.getTicket(id).pipe(
            map((ticket) => TicketActions.loadTicketSuccess({ ticket })),
            catchError((error) =>
              of(TicketActions.loadTicketFailure({ error: error.message }))
            )
          )
      )
    )
  );

  // Create — exhaustMap prevents duplicate submissions
  createTicket$ = createEffect(() =>
    this.actions$.pipe(
      ofType(TicketActions.createTicket),
      exhaustMap(({ data }) =>
        this.ticketService.createTicket(data).pipe(
          map((ticket) => TicketActions.createTicketSuccess({ ticket })),
          catchError((error) =>
            of(TicketActions.createTicketFailure({ error: error.message }))
          )
        )
      )
    )
  );

  // Navigate after creation
  createTicketSuccess$ = createEffect(
    () =>
      this.actions$.pipe(
        ofType(TicketActions.createTicketSuccess),
        tap(({ ticket }) => this.router.navigate(['/tickets', ticket.id]))
      ),
    { dispatch: false } // This effect doesn't dispatch a new action
  );

  // withLatestFrom: read from store inside an effect
  updateTicketStatus$ = createEffect(() =>
    this.actions$.pipe(
      ofType(TicketActions.updateTicketStatus),
      withLatestFrom(this.store.select(selectSelectedTicketId)),
      mergeMap(([{ id, status }, selectedId]) =>
        this.ticketService.updateStatus(id, status).pipe(
          map((ticket) => TicketActions.updateTicketStatusSuccess({ ticket })),
          catchError((error) =>
            of(
              TicketActions.updateTicketStatusFailure({
                id,
                error: error.message,
              })
            )
          )
        )
      )
    )
  );

  // Non-dispatching effect — analytics tracking
  trackTicketView$ = createEffect(
    () =>
      this.actions$.pipe(
        ofType(TicketActions.selectTicket),
        tap(({ id }) => {
          if (id) this.analyticsService.track('ticket_viewed', { id });
        })
      ),
    { dispatch: false }
  );
}

The operator choice matters here. I covered this in the Angular deep knowledge article, but it bears repeating in the NgRx context:

  • switchMap — for loads where you only want the latest result (search, route param changes)
  • mergeMap — for independent operations that can run in parallel (loading individual entities by ID)
  • concatMap — for operations that must complete in sequence (ordered processing)
  • exhaustMap — for operations where duplicates must be ignored (form submission)

Getting this wrong doesn’t cause a compile error. It causes intermittent runtime bugs that are hard to reproduce.

Handling errors — never let an effect die

The most critical rule in NgRx effects: always catch errors inside the inner Observable, never outside it. If an uncaught error reaches the outer Observable, the effect stream terminates and will never respond to that action again for the lifetime of the application.

// WRONG — an error here kills the effect stream permanently
loadTickets$ = createEffect(() =>
  this.actions$.pipe(
    ofType(TicketActions.loadTickets),
    switchMap(() => this.ticketService.getTickets()),
    map((tickets) => TicketActions.loadTicketsSuccess({ tickets })),
    catchError((error) => of(TicketActions.loadTicketsFailure({ error })))
    // ❌ catchError here catches the outer stream error
    // After one failure, loadTickets will never work again
  )
);

// CORRECT — catchError inside the inner Observable
loadTickets$ = createEffect(() =>
  this.actions$.pipe(
    ofType(TicketActions.loadTickets),
    switchMap(() =>
      this.ticketService.getTickets().pipe(
        // ← inner pipe
        map((tickets) => TicketActions.loadTicketsSuccess({ tickets })),
        catchError((error) => of(TicketActions.loadTicketsFailure({ error })))
        // ✅ catchError here only catches errors from this specific inner call
        // The outer stream continues and responds to the next action
      )
    )
  )
);

I’ve found dead effects in production codebases from exactly this mistake. The symptom is a feature that loads once and then stops working until the page is refreshed. The DevTools show the action being dispatched — but no effect ever responds.

Component Store: Local State Without the Global Store

@ngrx/component-store is NgRx for component-level state. It gives you the same patterns — state, selectors, updaters, effects — but scoped to a single component or component subtree. It’s destroyed when the component is destroyed.

This is the one I reach for when the problem is too complex for component state but not complex enough for the global store.

// ticket-filter.store.ts
import { Injectable } from '@angular/core';
import { ComponentStore } from '@ngrx/component-store';
import { switchMap, tap, catchError } from 'rxjs/operators';
import { EMPTY } from 'rxjs';

interface TicketFilterState {
  statusFilter: TicketStatus | 'all';
  priorityFilter: Priority | 'all';
  searchQuery: string;
  tickets: Ticket[];
  loading: boolean;
  error: string | null;
}

@Injectable()
export class TicketFilterStore extends ComponentStore<TicketFilterState> {
  private ticketService = inject(TicketService);

  constructor() {
    super({
      statusFilter: 'all',
      priorityFilter: 'all',
      searchQuery: '',
      tickets: [],
      loading: false,
      error: null,
    });
  }

  // Selectors
  readonly tickets$ = this.select((state) => state.tickets);
  readonly loading$ = this.select((state) => state.loading);
  readonly statusFilter$ = this.select((state) => state.statusFilter);
  readonly searchQuery$ = this.select((state) => state.searchQuery);

  // Derived selector — automatically recomputes when inputs change
  readonly filteredTickets$ = this.select(
    this.tickets$,
    this.statusFilter$,
    this.searchQuery$,
    (tickets, status, query) => {
      let filtered = tickets;
      if (status !== 'all') {
        filtered = filtered.filter((t) => t.status === status);
      }
      if (query.trim()) {
        const q = query.toLowerCase();
        filtered = filtered.filter(
          (t) =>
            t.subject.toLowerCase().includes(q) ||
            t.id.toLowerCase().includes(q)
        );
      }
      return filtered;
    }
  );

  // Updaters — synchronous state updates
  readonly setStatusFilter = this.updater(
    (state, statusFilter: TicketStatus | 'all') => ({ ...state, statusFilter })
  );

  readonly setSearchQuery = this.updater((state, searchQuery: string) => ({
    ...state,
    searchQuery,
  }));

  // Effects — async operations
  readonly loadTickets = this.effect<void>((trigger$) =>
    trigger$.pipe(
      tap(() => this.patchState({ loading: true, error: null })),
      switchMap(() =>
        this.ticketService.getTickets().pipe(
          tap((tickets) => this.patchState({ tickets, loading: false })),
          catchError((error) => {
            this.patchState({ error: error.message, loading: false });
            return EMPTY;
          })
        )
      )
    )
  );
}

In the component:

@Component({
  selector: 'app-ticket-filter',
  standalone: true,
  providers: [TicketFilterStore], // Scoped to this component
  template: `
    <input
      [value]="store.searchQuery$ | async"
      (input)="store.setSearchQuery($event.target.value)"
    />
    <app-ticket-list [tickets]="store.filteredTickets$ | async" />
  `,
})
export class TicketFilterComponent implements OnInit {
  store = inject(TicketFilterStore);

  ngOnInit() {
    this.store.loadTickets();
  }
}

When TicketFilterComponent is destroyed, the TicketFilterStore instance is destroyed with it. No cleanup needed. No global state pollution.

NgRx Signals Store — The Modern Approach

@ngrx/signals (v17+) is the signals-native state management solution. It’s not a replacement for the global Store — it’s a replacement for ComponentStore in most cases, and for simple global state in others.

// ticket-filter.store.ts — signals version
import {
  signalStore,
  withState,
  withComputed,
  withMethods,
  patchState,
} from '@ngrx/signals';
import { withEntities, setAllEntities } from '@ngrx/signals/entities';
import { computed, inject } from '@angular/core';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { pipe, switchMap, tap, catchError, EMPTY } from 'rxjs';

type TicketFilterState = {
  statusFilter: TicketStatus | 'all';
  searchQuery: string;
  loading: boolean;
  error: string | null;
};

export const TicketFilterStore = signalStore(
  withEntities<Ticket>(),
  withState<TicketFilterState>({
    statusFilter: 'all',
    searchQuery: '',
    loading: false,
    error: null,
  }),

  withComputed(({ entities, statusFilter, searchQuery }) => ({
    filteredTickets: computed(() => {
      let filtered = entities();
      const status = statusFilter();
      const query = searchQuery().toLowerCase().trim();

      if (status !== 'all') {
        filtered = filtered.filter((t) => t.status === status);
      }
      if (query) {
        filtered = filtered.filter((t) =>
          t.subject.toLowerCase().includes(query)
        );
      }
      return filtered;
    }),

    openCount: computed(
      () => entities().filter((t) => t.status === 'open').length
    ),
  })),

  withMethods((store, ticketService = inject(TicketService)) => ({
    setStatusFilter(status: TicketStatus | 'all') {
      patchState(store, { statusFilter: status });
    },

    setSearchQuery(query: string) {
      patchState(store, { searchQuery: query });
    },

    loadTickets: rxMethod<void>(
      pipe(
        tap(() => patchState(store, { loading: true, error: null })),
        switchMap(() =>
          ticketService.getTickets().pipe(
            tap((tickets) => {
              patchState(store, setAllEntities(tickets), { loading: false });
            }),
            catchError((error) => {
              patchState(store, { error: error.message, loading: false });
              return EMPTY;
            })
          )
        )
      )
    ),
  }))
);

In the component:

@Component({
  selector: 'app-ticket-filter',
  standalone: true,
  providers: [TicketFilterStore],
  template: `
    <input
      [value]="store.searchQuery()"
      (input)="store.setSearchQuery($event.target.value)"
    />
    <p>{{ store.openCount() }} open tickets</p>
    <app-ticket-list [tickets]="store.filteredTickets()" />
  `,
})
export class TicketFilterComponent implements OnInit {
  store = inject(TicketFilterStore);

  ngOnInit() {
    this.store.loadTickets();
  }
}

Everything is a signal. No async pipe. No subscription management. Computed values update automatically when their dependencies change. withEntities gives you the entity adapter’s normalisation and methods in signal form.

Advanced Patterns: What Experience Teaches

Optimistic updates with rollback

Optimistic updates show the change immediately, before the server confirms it. If the server returns an error, you roll back:

// In the reducer — store the previous value for rollback
(on(TicketActions.updateTicketStatus, (state, { id, status }) => {
  const previousStatus = state.entities[id]?.status;
  return {
    ...ticketAdapter.updateOne({ id, changes: { status } }, state),
    // Store previous status for potential rollback
    pendingUpdates: {
      ...state.pendingUpdates,
      [id]: { previousStatus },
    },
  };
}),
  on(TicketActions.updateTicketStatusFailure, (state, { id }) => {
    const previous = state.pendingUpdates[id];
    const rollbackState = previous
      ? ticketAdapter.updateOne(
          { id, changes: { status: previous.previousStatus } },
          state
        )
      : state;

    const { [id]: _, ...remainingPending } = state.pendingUpdates;
    return { ...rollbackState, pendingUpdates: remainingPending };
  }),
  on(TicketActions.updateTicketStatusSuccess, (state, { id }) => {
    const { [id]: _, ...remainingPending } = state.pendingUpdates;
    return { ...state, pendingUpdates: remainingPending };
  }));

Meta-reducers: cross-cutting concerns

Meta-reducers wrap all reducers and can intercept every action. Use them for logging, hydration from localStorage, and resetting state on logout:

// reset-on-logout.meta-reducer.ts
export function resetOnLogoutMetaReducer(
  reducer: ActionReducer<AppState>
): ActionReducer<AppState> {
  return (state, action) => {
    if (action.type === AuthActions.logout.type) {
      // Pass undefined as state to reset all reducers to initial state
      return reducer(undefined, action);
    }
    return reducer(state, action);
  };
}

// hydration.meta-reducer.ts
export function hydrationMetaReducer(
  reducer: ActionReducer<AppState>
): ActionReducer<AppState> {
  return (state, action) => {
    if (action.type === INIT) {
      const storageValue = localStorage.getItem('appState');
      if (storageValue) {
        try {
          return JSON.parse(storageValue);
        } catch {
          localStorage.removeItem('appState');
        }
      }
    }

    const nextState = reducer(state, action);

    // Persist specific slices — never persist everything
    localStorage.setItem(
      'appState',
      JSON.stringify({
        userPreferences: nextState.userPreferences,
      })
    );

    return nextState;
  };
}

// Provide them
provideStore(reducers, {
  metaReducers: [resetOnLogoutMetaReducer, hydrationMetaReducer],
});

Only persist what needs to be persisted. Persisting the entire state to localStorage is a common mistake — it grows unboundedly and causes stale data problems after deployments.

Action creator typing — strict event contracts

Use TypeScript to enforce that actions carry the right payload. Never use any in action props:

// Instead of this
props<{ data: any }>();

// Use specific types
props<{ ticket: Ticket }>();
props<{ updates: Partial<Pick<Ticket, 'status' | 'priority'>> }>();
props<{ ids: readonly string[] }>();

Readonly arrays in action props prevent mutation:

export const TicketActions = createActionGroup({
  source: 'Tickets',
  events: {
    'Bulk Update Status': props<{
      ids: readonly string[];
      status: TicketStatus;
    }>(),
  },
});

Selector composition for performance

Selectors are memoised by reference equality. Understanding when memoisation helps and when it doesn’t prevents subtle performance issues:

// This selector creates a new array every time — memoisation won't help
// because the output is always a new reference
export const selectOpenTickets = createSelector(
  selectAllTickets,
  (tickets) => tickets.filter((t) => t.status === 'open')
  // Array.filter always returns a new array
  // But NgRx memoises the SELECTOR RESULT — if selectAllTickets
  // returns the same reference, filter won't run again
);

// Use createSelectorFactory with a custom memoiser for deep equality
import { createSelectorFactory, defaultMemoize } from '@ngrx/store';
import { isEqual } from 'lodash';

const createDeepEqualSelector = createSelectorFactory((projector) =>
  defaultMemoize(projector, isEqual, isEqual)
);

// Use sparingly — deep equality is more expensive than reference equality
export const selectFilteredConfig = createDeepEqualSelector(
  selectRawConfig,
  (config) => processConfig(config)
);

Effect composition — effects talking to each other

Effects can dispatch actions that other effects listen to. This is how you build effect chains without nesting:

// First effect: load the ticket
loadTicket$ = createEffect(() =>
  this.actions$.pipe(
    ofType(TicketActions.loadTicket),
    switchMap(({ id }) =>
      this.ticketService.getTicket(id).pipe(
        map((ticket) => TicketActions.loadTicketSuccess({ ticket })),
        catchError((error) => of(TicketActions.loadTicketFailure({ error })))
      )
    )
  )
);

// Second effect: load related account when a ticket loads
loadAccountForTicket$ = createEffect(() =>
  this.actions$.pipe(
    ofType(TicketActions.loadTicketSuccess),
    map(({ ticket }) => AccountActions.loadAccount({ id: ticket.accountId }))
  )
);

// Third effect: handles the account load
// Defined in AccountEffects but triggered by a ticket action

This keeps each effect focused on one responsibility while the composition handles the orchestration.

RouterStore — syncing the router with the store

@ngrx/router-store puts router state into the NgRx store, making route information available as selectors and enabling routing actions:

// Setup
(provideStore({
  router: routerReducer,
  tickets: ticketReducer,
}),
  provideRouterStore());

// Selectors
import { getRouterSelectors } from '@ngrx/router-store';

export const {
  selectCurrentRoute,
  selectQueryParams,
  selectRouteParams,
  selectUrl,
} = getRouterSelectors();

// Usage — select the current ticket ID from the route
export const selectCurrentTicketId = createSelector(
  selectRouteParams,
  (params) => params['id'] as string | undefined
);

export const selectCurrentTicket = createSelector(
  selectTicketEntities,
  selectCurrentTicketId,
  (entities, id) => (id ? entities[id] : null)
);

Now the route is part of the state graph. You can derive data from both the route and the store in a single selector, without components needing to inject ActivatedRoute.

Testing NgRx

Testing reducers is straightforward — they’re pure functions:

describe('ticketReducer', () => {
  it('should set loading true on loadTickets', () => {
    const state = initialState;
    const action = TicketActions.loadTickets();
    const result = ticketReducer(state, action);

    expect(result.loading).toBe(true);
    expect(result.error).toBeNull();
  });

  it('should add tickets on loadTicketsSuccess', () => {
    const tickets = [mockTicket({ id: 't-001' }), mockTicket({ id: 't-002' })];
    const action = TicketActions.loadTicketsSuccess({ tickets });
    const result = ticketReducer(initialState, action);

    expect(result.ids).toEqual(['t-001', 't-002']);
    expect(result.loading).toBe(false);
  });
});

Testing selectors:

describe('selectOpenTickets', () => {
  it('should return only open tickets', () => {
    const state = {
      tickets: ticketAdapter.setAll(
        [
          mockTicket({ id: 't-001', status: 'open' }),
          mockTicket({ id: 't-002', status: 'resolved' }),
        ],
        initialState
      ),
    };

    const result = selectOpenTickets.projector(
      selectAllTickets.projector(state.tickets)
    );

    expect(result.length).toBe(1);
    expect(result[0].id).toBe('t-001');
  });
});

Testing effects with provideMockActions:

describe('TicketEffects', () => {
  let effects: TicketEffects;
  let actions$: Observable<Action>;
  let ticketService: jasmine.SpyObj<TicketService>;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        TicketEffects,
        provideMockActions(() => actions$),
        {
          provide: TicketService,
          useValue: jasmine.createSpyObj('TicketService', ['getTickets']),
        },
      ],
    });

    effects = TestBed.inject(TicketEffects);
    ticketService = TestBed.inject(
      TicketService
    ) as jasmine.SpyObj<TicketService>;
  });

  it('should dispatch loadTicketsSuccess on success', () => {
    const tickets = [mockTicket()];
    ticketService.getTickets.and.returnValue(of(tickets));

    actions$ = of(TicketActions.loadTickets());

    effects.loadTickets$.subscribe((action) => {
      expect(action).toEqual(TicketActions.loadTicketsSuccess({ tickets }));
    });
  });

  it('should dispatch loadTicketsFailure on error', () => {
    ticketService.getTickets.and.returnValue(
      throwError(() => new Error('Network error'))
    );

    actions$ = of(TicketActions.loadTickets());

    effects.loadTickets$.subscribe((action) => {
      expect(action).toEqual(
        TicketActions.loadTicketsFailure({ error: 'Network error' })
      );
    });
  });
});

How I Teach NgRx

When I introduce NgRx to a team that hasn’t used it, I follow a specific sequence. Jumping straight to the full pattern overwhelms people and the boilerplate obscures the reasoning.

Step 1: Feel the problem first. Before any NgRx code, I have the team build a feature with a shared service and plain Observables. We find a real scenario where the state gets out of sync, or where debugging is difficult, or where the loading/error state gets inconsistent. The problem has to be felt, not described.

Step 2: Introduce actions and reducers alone. No effects, no selectors yet. Just the state shape, some actions, and a reducer. Run it through a few action dispatches in the DevTools. The time-travel debugging is the first moment the investment starts to feel worth it.

Step 3: Add selectors. Show the memoization benefit. Show how selectors compose. Show how a component that used to inject multiple services now injects one selector.

Step 4: Add effects. This is where the pattern clicks. The component dispatches loadTickets(). The effect intercepts it, calls the service, dispatches loadTicketsSuccess or loadTicketsFailure. The reducer handles those. The selector exposes the result. The component never touched the service directly.

Step 5: Entity adapter. Once the pattern is solid, introduce normalization and the entity methods. By this point, the adapter’s methods make intuitive sense because the team understands what state shape they’re managing.

Step 6: Component Store and Signals Store. Finally, show the alternatives for local state. The team now understands the global store well enough to recognise when they need it and when they don’t.

This sequence takes longer than showing the full pattern upfront. It produces developers who understand why every piece exists, which means they make better decisions about when to use each piece.

The NgRx Decision I Make Every Time

At the start of every feature I’m involved in, I ask the same question: does this state need to be global?

If two or more unrelated parts of the application need to read it — global store. If only one component subtree needs it — Component Store or Signals Store. If only one component needs it — component state.

NgRx’s global store is not the default. It’s the escalation. You reach for it when the simpler options genuinely don’t fit.

The projects where I’ve seen NgRx cause the most problems are the ones where it was adopted as a project-wide standard before anyone asked whether each piece of state actually needed to be global. Every dropdown, every form field, every pagination offset — all in the store. The result is thousands of actions for trivial state changes, reducers that manage nothing meaningful, and a DevTools log so noisy it’s unusable.

The store should contain what genuinely needs to be shared, tracked, and time-travelled. Everything else belongs closer to where it’s used.

Conclusion

NgRx is one of the most powerful tools in the Angular ecosystem and one of the most frequently misapplied ones. The ceremony it requires — actions, reducers, selectors, effects — is not overhead for its own sake. Each piece serves a purpose. Actions make state changes explicit and traceable. Reducers make state transitions pure and testable. Selectors make derived data memoised and composable. Effects isolate side effects and keep components unaware of async complexity.

When the problem is complex enough — shared state, optimistic updates, time-travel debugging, large teams — NgRx pays for itself many times over. When the problem isn’t that complex, it’s the wrong tool, and using it anyway creates a codebase that’s harder to understand and maintain than the service-based alternative it replaced.

The newer version of NgRx — createActionGroup, @ngrx/signals, functional effects, signalStore — is significantly better than the version that exists in most tutorials. If your understanding of NgRx is from a course that used action classes and @Effect() decorators, the patterns have changed in ways that make the library substantially less verbose and substantially more type-safe.

Resources