Skip to content
Angular ng libraries 5 min read

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 modelRedux: actions + reducersSignal store with methods
BoilerplateHigher (actions/reducers/selectors)Low
DevTools time-travelFull supportPartial
Best forLarge, shared, audited stateFeature/component-scoped state
AsyncEffectsrxMethod / 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/entity for any collection to avoid error-prone manual array updates.
  • Register feature state lazily with provideState/provideEffects in lazy routes.
  • Reach for @ngrx/signals when full Redux ceremony is overkill for local state.
  • Keep the dev tools enabled in development for time-travel debugging and action inspection.
Last updated June 14, 2026
Was this helpful?