Angular Advanced #5 Intro to NgRx — Store, Action, Reducer, Effect
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
updatedirectly - 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 #
- Single source of truth — Keep the entire app’s state inside one store
- Immutable state — Don’t mutate state; always replace it with a new object
- Unidirectional flow — Components dispatch Actions, the Reducer produces a new state, and components read only via Selectors
In one diagram:
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 signals — store.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
A quick heuristic for “should I use NgRx?”.
- The same data is viewed by 3+ features and needs real-time sync across screens
- Multiple async flows (loading/error/cancel) live on the same page
- There’s value in tracing hard-to-reproduce QA bugs with time-travel debugging
- 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+provideEffectsstandalone 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.