NgRx
NgRx is the most widely used state management library for Angular, bringing the Redux pattern—a single immutable store, pure reducers, and unidirectional data flow—to the framework with first-class RxJS and signals integration. It shines in large applications where many components share state, where you need predictable updates, time-travel debugging, and a clear audit trail of why state changed. NgRx is a family of packages: @ngrx/store for the state container, @ngrx/effects for side effects, @ngrx/entity for collection management, and @ngrx/signals for a lighter, signal-native alternative.
Installing NgRx
The schematics wire up the store, dev tools, and provide functions for you.
ng add @ngrx/store@latest
ng add @ngrx/effects@latest
ng add @ngrx/entity@latest
ng add @ngrx/store-devtools@latest
In a standalone app you register everything through provideStore, provideEffects, and provideStoreDevtools in app.config.ts.
import { ApplicationConfig, isDevMode } from '@angular/core';
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: [
provideStore({ todos: todosReducer }),
provideEffects(TodosEffects),
provideStoreDevtools({ maxAge: 25, logOnly: !isDevMode() }),
],
};
The core loop: actions, reducers, selectors
NgRx state flows in one direction. A component dispatches an action, a pure reducer computes the next state from the current state plus the action, and components read slices of state through selectors. Nothing mutates state in place—reducers always return a new object.
Define actions with createActionGroup, which keeps related events together and gives you well-typed creators.
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import { Todo } from './todo.model';
export const TodosActions = createActionGroup({
source: 'Todos',
events: {
'Load Todos': emptyProps(),
'Load Todos Success': props<{ todos: Todo[] }>(),
'Load Todos Failure': props<{ error: string }>(),
'Add Todo': props<{ title: string }>(),
'Toggle Todo': props<{ id: string }>(),
},
});
The reducer responds to those actions. Use createReducer with on handlers; each handler receives the current state and returns a fresh copy.
import { createReducer, on } from '@ngrx/store';
import { TodosActions } from './todos.actions';
import { Todo } from './todo.model';
export interface TodosState {
items: Todo[];
loading: boolean;
error: string | null;
}
const initialState: TodosState = { items: [], loading: false, error: null };
export const todosReducer = createReducer(
initialState,
on(TodosActions.loadTodos, (state) => ({ ...state, loading: true, error: null })),
on(TodosActions.loadTodosSuccess, (state, { todos }) => ({
...state,
items: todos,
loading: false,
})),
on(TodosActions.loadTodosFailure, (state, { error }) => ({
...state,
loading: false,
error,
})),
on(TodosActions.toggleTodo, (state, { id }) => ({
...state,
items: state.items.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
})),
);
Selectors are memoized, composable queries over the store.
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { TodosState } from './todos.reducer';
export const selectTodosState = createFeatureSelector<TodosState>('todos');
export const selectAllTodos = createSelector(selectTodosState, (s) => s.items);
export const selectLoading = createSelector(selectTodosState, (s) => s.loading);
export const selectPendingCount = createSelector(
selectAllTodos,
(todos) => todos.filter((t) => !t.done).length,
);
Reading state in components
Inject the Store and use selectSignal to project state into signals, which integrate cleanly with the new control flow.
import { Component, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { TodosActions } from './todos.actions';
import { selectAllTodos, selectLoading, selectPendingCount } from './todos.selectors';
@Component({
selector: 'app-todos',
standalone: true,
template: `
@if (loading()) {
<p>Loading…</p>
} @else {
<p>{{ pending() }} remaining</p>
<ul>
@for (todo of todos(); track todo.id) {
<li (click)="toggle(todo.id)">{{ todo.title }}</li>
}
</ul>
}
`,
})
export class TodosComponent {
private store = inject(Store);
todos = this.store.selectSignal(selectAllTodos);
loading = this.store.selectSignal(selectLoading);
pending = this.store.selectSignal(selectPendingCount);
constructor() {
this.store.dispatch(TodosActions.loadTodos());
}
toggle(id: string) {
this.store.dispatch(TodosActions.toggleTodo({ id }));
}
}
Effects: handling side effects
Reducers must stay pure, so anything asynchronous—HTTP calls, timers, navigation—lives in effects. An effect listens for an action, performs work, and dispatches a result action.
import { inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, map, of, switchMap } from 'rxjs';
import { TodosActions } from './todos.actions';
import { TodosService } from './todos.service';
export class TodosEffects {
private actions$ = inject(Actions);
private service = inject(TodosService);
loadTodos$ = createEffect(() =>
this.actions$.pipe(
ofType(TodosActions.loadTodos),
switchMap(() =>
this.service.getAll().pipe(
map((todos) => TodosActions.loadTodosSuccess({ todos })),
catchError((err) => of(TodosActions.loadTodosFailure({ error: err.message }))),
),
),
),
);
}
Always terminate the inner observable with
catchError. If an error reaches the outer stream, the effect dies and stops reacting to future actions.
Managing collections with @ngrx/entity
Most stores hold lists of records. @ngrx/entity normalizes a collection into { ids, entities } and provides adapter helpers for add/update/remove, eliminating hand-written array logic.
import { createEntityAdapter, EntityState } from '@ngrx/entity';
import { createReducer, on } from '@ngrx/store';
import { TodosActions } from './todos.actions';
import { Todo } from './todo.model';
export const adapter = createEntityAdapter<Todo>();
export interface TodosState extends EntityState<Todo> {
loading: boolean;
}
const initialState = adapter.getInitialState({ loading: false });
export const todosReducer = createReducer(
initialState,
on(TodosActions.loadTodosSuccess, (state, { todos }) =>
adapter.setAll(todos, { ...state, loading: false }),
),
);
export const { selectAll, selectEntities, selectTotal } = adapter.getSelectors();
@ngrx/store vs @ngrx/signals
For feature-local or moderately sized state, the newer @ngrx/signals signalStore offers a lighter, boilerplate-free API built directly on signals.
| Aspect | @ngrx/store | @ngrx/signals |
|---|---|---|
| Mental model | Redux: actions + reducers | Signal store with methods |
| Boilerplate | Higher (actions/reducers/selectors) | Low |
| DevTools time-travel | Full support | Partial |
| Best for | Large, shared, audited state | Feature/component-scoped state |
| Async | Effects | rxMethod / promises |
Best Practices
- Keep reducers pure and synchronous—push every async or impure operation into an effect.
- Read state only through memoized selectors so derived data is computed once and shared.
- Name actions after the event that occurred (“Todo Toggled”), not the state mutation you want.
- Use
@ngrx/entityfor any collection to avoid error-prone manual array updates. - Register feature state lazily with
provideState/provideEffectsin lazy routes. - Reach for
@ngrx/signalswhen full Redux ceremony is overkill for local state. - Keep the dev tools enabled in development for time-travel debugging and action inspection.