Angular Advanced #5 Intro to NgRx — Store, Action, Reducer, Effect

11 min read

Last time we went deep on RxJS — the grain of operators. This time we ride on top of that with a state-management library, NgRx.

As we saw in Basics #5 Service/DI, Angular can manage the state of a small app pretty cleanly with just Service + Signals, no extra library. Honestly, that’s the right answer for many apps. The reason we still set aside a whole post for NgRx is that the moment an app crosses a certain size, “tracking where and how state is changing” itself starts to demand a separate tool.

In this post we’ll start with the Redux pattern — the background NgRx came out of — and walk through the package layout, the roles of Store / Action / Reducer / Selector / Effect, modern standalone setup, and the lighter alternatives Component Store and SignalStore.

Where Service + Signal stops working #

The pattern from Basics #5 — a Service with providedIn: 'root' that holds signal + computed and only allows changes through methods — is already enough for small apps. Trouble usually starts in places like these.

  • Multiple features start looking at the same data in different shapes
  • “Who changed this value, when?” can no longer be traced — because many components call update directly
  • Multiple async flows (loading/error/cancel) are running at the same time on a single page

By this point, you need “a tool that gathers state changes into one place, gives them names, and lets you inspect the flow with logs / time-travel.” That’s Redux, and the Angular flavor is NgRx.

The Redux pattern — three principles #

  1. Single source of truth — Keep the entire app’s state inside one store
  2. Immutable state — Don’t mutate state; always replace it with a new object
  3. Unidirectional flow — Components dispatch Actions, the Reducer produces a new state, and components read only via Selectors

In one diagram:

NgRx unidirectional flow
Component ──dispatch(Action)──► Store ──► Reducer(prev, action) ──► newState
   ▲                              │                                    │
   └──── select(Selector) ────────┴────────────────────────────────────┘

The slot where async side effects (API calls, routing) come in is Effect. An Effect listens to actions → performs async work → dispatches the result as another action. Even side effects are expressed in the same language — “action → action.” That consistency is what shines in large apps.

NgRx package layout #

NgRx isn’t a single piece; it’s split into several packages. You pick only what you need.

  • @ngrx/store — Redux-style global store. Action / Reducer / Selector
  • @ngrx/effects — Tools to handle async side effects via the action stream
  • @ngrx/entity — An adapter for collection state (lists, id maps). Cuts CRUD boilerplate
  • @ngrx/component-store — A lightweight local store at the feature/component level
  • @ngrx/signals — The new model for modern Angular. SignalStore. Signal-friendly design
  • @ngrx/router-store — Syncs router state into the store

Install with ng add @ngrx/store@latest and ng add @ngrx/effects@latest. ng add also handles the standalone setup. In this post we’ll center on store + effects to see the flow, and at the end briefly compare Component Store and SignalStore.

Example domain — Todos #

Before diving in, pin a domain. Todos — a list where the user adds/removes todos and we fetch the initial list from the server.

src/app/todos/todos.state.ts
export interface Todo { id: number; title: string; done: boolean; }

export interface TodosState {
  items: Todo[];
  loading: boolean;
  error: string | null;
}

export const initialTodosState: TodosState = {
  items: [],
  loading: false,
  error: null,
};

Action — name what happened #

An Action is a plain object that expresses “what happened.” The createAction helper defines the type and payload cleanly.

src/app/todos/todos.actions.ts
import { createAction, props } from '@ngrx/store';
import { Todo } from './todos.state';

export const loadTodos = createAction('[Todos] Load');
export const loadTodosSuccess = createAction(
  '[Todos] Load Success',
  props<{ items: Todo[] }>(),
);
export const loadTodosFailure = createAction(
  '[Todos] Load Failure',
  props<{ error: string }>(),
);

export const addTodo = createAction('[Todos] Add', props<{ title: string }>());
export const removeTodo = createAction('[Todos] Remove', props<{ id: number }>());

The [Todos] prefix is a convention to tell at a glance, in DevTools logs, where an action came from. The standard form is [screen/feature name] verb, and props<{ ... }>() pins the payload type, so wherever you handle the action — reducer, effect, component — the type follows you all the way through.

Reducer — produces a new state #

A Reducer is a pure function shaped like (previous state, action) => new state.

src/app/todos/todos.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { initialTodosState } from './todos.state';
import * as TodosActions from './todos.actions';

export const todosReducer = createReducer(
  initialTodosState,

  on(TodosActions.loadTodos, (state) => ({ ...state, loading: true, error: null })),
  on(TodosActions.loadTodosSuccess, (state, { items }) => ({
    ...state, loading: false, items,
  })),
  on(TodosActions.loadTodosFailure, (state, { error }) => ({
    ...state, loading: false, error,
  })),

  on(TodosActions.addTodo, (state, { title }) => ({
    ...state,
    items: [...state.items, { id: Date.now(), title, done: false }],
  })),
  on(TodosActions.removeTodo, (state, { id }) => ({
    ...state,
    items: state.items.filter((t) => t.id !== id),
  })),
);

The point is immutable updates. Don’t mutate with state.items.push(...) — always build new objects/arrays with spread and return them. Recall that what OnPush and signals use for change detection is reference comparison — it’s a natural rule.

Note
Before NgRx 8, reducers were written as a giant switch. Today the standard is createReducer + on(), and there’s no need to follow the old style for new code. The internals are the same, but type inference and readability are much better.

Selector — components only read via select #

When components pull data from the store, they don’t read state.todos.items directly; they go through a small function called a Selector. This one layer does two things — if the state shape changes you only fix the selectors and components stay untouched, and memoization returns the previous result when inputs are the same.

src/app/todos/todos.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { TodosState } from './todos.state';

export const selectTodosState = createFeatureSelector<TodosState>('todos');

export const selectTodos = createSelector(selectTodosState, (s) => s.items);
export const selectLoading = createSelector(selectTodosState, (s) => s.loading);

export const selectRemainingCount = createSelector(
  selectTodos,
  (items) => items.filter((t) => !t.done).length,
);

createSelector doesn’t re-run the lower function if the upper selector’s output is referentially the same. Even expensive derivations are safe.

Effects — side effects via action streams #

Reducers are pure functions, so you can’t slot HTTP calls inside them. API calls, routing, local storage — side effects belong in Effects.

src/app/todos/todos.effects.ts
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, map, of, switchMap } from 'rxjs';

import { TodosApi } from './todos.api';
import * as TodosActions from './todos.actions';

@Injectable()
export class TodosEffects {
  private readonly actions$ = inject(Actions);
  private readonly api = inject(TodosApi);

  loadTodos$ = createEffect(() =>
    this.actions$.pipe(
      ofType(TodosActions.loadTodos),
      switchMap(() =>
        this.api.fetchAll().pipe(
          map((items) => TodosActions.loadTodosSuccess({ items })),
          catchError((err) =>
            of(TodosActions.loadTodosFailure({ error: err.message })),
          ),
        ),
      ),
    ),
  );
}

The flow reads in one line — when loadTodos arrives, call the API and dispatch the result back as loadTodosSuccess or loadTodosFailure. Components only need to dispatch loadTodos and read selectLoading / selectTodos.

switchMap cancels the previous request when a new one comes in. It fits naturally for cases like a search box that fires in quick succession, and the RxJS operator categorization from the previous post carries over.

Setup — modern standalone pattern #

NgRx now offers function-style providers provideStore / provideEffects to fit the standalone era. Wire them up in one place — app.config.ts.

src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { provideStoreDevtools } from '@ngrx/store-devtools';

import { todosReducer } from './todos/todos.reducer';
import { TodosEffects } from './todos/todos.effects';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(),
    provideStore({ todos: todosReducer }),
    provideEffects([TodosEffects]),
    provideStoreDevtools({ maxAge: 50, connectInZone: false }),
  ],
};

As features grow, you can attach provideState('feature-name', featureReducer) per route to lazy load them. In large apps, slicing the store by feature from the start helps maintenance.

In components — dispatch and select #

Components do only two things. Dispatch actions and select state.

src/app/todos/todos-page.component.ts
import { Component, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { Store } from '@ngrx/store';

import * as TodosActions from './todos.actions';
import { selectLoading, selectRemainingCount, selectTodos } from './todos.selectors';

@Component({
  selector: 'app-todos-page',
  standalone: true,
  imports: [AsyncPipe],
  template: `
    @if (loading$ | async) {
      <p>Loading…</p>
    } @else {
      @for (todo of (todos$ | async) ?? []; track todo.id) {
        <p>{{ todo.title }} <button (click)="remove(todo.id)">Remove</button></p>
      }
      <p>Remaining: {{ remaining$ | async }}</p>
    }
  `,
})
export class TodosPageComponent {
  private readonly store = inject(Store);

  protected readonly todos$ = this.store.select(selectTodos);
  protected readonly loading$ = this.store.select(selectLoading);
  protected readonly remaining$ = this.store.select(selectRemainingCount);

  ngOnInit() { this.store.dispatch(TodosActions.loadTodos()); }
  remove(id: number) { this.store.dispatch(TodosActions.removeTodo({ id })); }
}

Selectors return Observables, so we sent them through the async pipe. If you want signal-friendly code, you can take them with store.selectSignal(selectTodos) and use them like signal(). OnPush plays well here.

Component Store — lighter and closer #

When a full global store feels like too much but you want something more organized than just a Service, use @ngrx/component-store. Register it on a component’s providers and it becomes a local store that lives only as long as that component lives. It dies when the page leaves — a model that fits local state. Built without action/reducer boilerplate, using just updater / effect methods, it’s a good fit for ephemeral state in a single component (edit forms, wizard steps, and the like).

SignalStore — the new standard for modern Angular #

The newest model is @ngrx/signals — short for SignalStore. A new design that puts signal first-class and minimizes RxJS dependence. No need to define separate actions/reducers/selectors — state, computed values, and methods sit in one place.

src/app/todos/todos.signal-store.ts
import { computed, inject } from '@angular/core';
import {
  signalStore, withState, withComputed, withMethods, patchState,
} from '@ngrx/signals';

import { TodosApi } from './todos.api';
import { Todo } from './todos.state';

export const TodosStore = signalStore(
  { providedIn: 'root' },
  withState({ items: [] as Todo[], loading: false, error: null as string | null }),
  withComputed(({ items }) => ({
    remaining: computed(() => items().filter((t) => !t.done).length),
  })),
  withMethods((store, api = inject(TodosApi)) => ({
    async load() {
      patchState(store, { loading: true, error: null });
      try {
        const items = await api.fetchAllAsPromise();
        patchState(store, { items, loading: false });
      } catch (err: any) {
        patchState(store, { error: err.message, loading: false });
      }
    },
    add(title: string) {
      patchState(store, ({ items }) => ({
        items: [...items, { id: Date.now(), title, done: false }],
      }));
    },
  })),
);

In components you take it with inject(TodosStore) and use it like signalsstore.items(), store.loading(), store.remaining(). For new modern Angular projects, SignalStore is increasingly the default first choice.

That said, DevTools time-travel and action-level audit logs remain classic NgRx’s strengths. If your environment requires tracing “which action happened in what order” all the way through in a large team, the classic side still carries weight.

When to use NgRx — the threshold guide #

The three models in one line each:

  • Service + Signal — Small apps, simple state shared by one or two screens
  • Component Store / SignalStore — Feature-scoped state, screens with some async flows
  • NgRx Store + Effects — Multiple features sharing the same domain data, large team collaboration, apps where flow tracing/auditing has high value
Tip

A quick heuristic for “should I use NgRx?”.

  1. The same data is viewed by 3+ features and needs real-time sync across screens
  2. Multiple async flows (loading/error/cancel) live on the same page
  3. There’s value in tracing hard-to-reproduce QA bugs with time-travel debugging
  4. The team is 4+ people and you want to enforce a convention for “where and how state changes”

Three or more matches and NgRx Store is worth introducing. One or two and SignalStore is lighter and faster. Pulling NgRx into a small app is almost always overkill.

Wrap-up #

In this post we did a quick intro to NgRx.

  • When state flow gets complex, the explicitness of the Redux pattern pays off
  • Three principles — single source of truth / immutable / unidirectional
  • Action is what happened, Reducer makes the new state, Selector is the read, Effect turns side effects into action streams
  • The modern setup is the provideStore + provideEffects standalone pattern
  • Component Store is a lightweight feature-scoped store; SignalStore is the new signal-friendly model
  • Small apps are fine with Service + Signal. NgRx is for when collaboration/domain crosses a threshold

The next post — “Angular Advanced #6 SSR and Hydration” — shifts focus beyond the client. We’ll take a careful look at SSR for Angular apps that pre-render on the server, and non-destructive hydration and incremental hydration that became the standard from Angular 17 — how to nail first paint and SEO at the same time.

X