NgRx Store in Modern Angular: Actions, Reducers, Selectors and Effects [2026]

Link copied
NgRx Store in Modern Angular: Actions, Reducers, Selectors and Effects [2026]

NgRx Store in Modern Angular: Actions, Reducers, Selectors and Effects [2026]

NgRx Store has a reputation for boilerplate, and the reputation comes mostly from code written years ago: action type constants, switch-statement reducers, hand-written selectors for every property, effect classes registered in NgModules. The modern API is much shorter. It is still more structured than a signal in a service, and that structure is the reason teams pick it: every change to state is a named, serialisable event you can log, replay and inspect.

This is lesson 14.3 of the Angular Tutorial. Lesson 14.2 covered NgRx Signal Store; this lesson covers its older sibling, @ngrx/store, written the 2026 way: standalone providers, createActionGroup, createFeature with generated selectors, selectSignal for reading state as signals, functional effects and Redux DevTools. It ends with when NgRx Store is still the right choice and how to migrate towards Signal Store gradually.

The Redux loop #

NgRx Store keeps one immutable state tree for the app and changes it only in response to actions:

Piece Role Written with
Action Describes something that happened, [Products Page] Opened createActionGroup
Reducer Pure function: (state, action) => newState createReducer + on
Feature Groups a name, a reducer and generated selectors createFeature
Selector Memoised read of a slice or derived value Generated, or createSelector
Effect Listens for actions, performs side effects, dispatches new actions createEffect

The flow is one-directional: a component dispatches an action, reducers produce the next state, selectors notify the components that read the changed slices, and effects react to actions that need I/O, such as loading data, then dispatch success or failure actions.

Setup #

ng add @ngrx/store@latest
ng add @ngrx/effects@latest
ng add @ngrx/store-devtools@latest

In a standalone app the providers go 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';

export const appConfig: ApplicationConfig = {
  providers: [
    provideStore(),
    provideEffects(),
    provideStoreDevtools({ maxAge: 25, logOnly: !isDevMode() }),
  ],
};

provideStore() with no arguments creates an empty root store; features register their own state later, often lazily per route.

Actions with createActionGroup #

Group actions by source, the place where the event happens. Name them as events ("Opened", "Search Changed"), not commands ("Load Products"):

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

export const ProductsPageActions = createActionGroup({
  source: 'Products Page',
  events: {
    Opened: emptyProps(),
    'Search Changed': props<{ query: string }>(),
  },
});

export const ProductsApiActions = createActionGroup({
  source: 'Products API',
  events: {
    'Products Loaded Success': props<{ products: Product[] }>(),
    'Products Loaded Failure': props<{ error: string }>(),
  },
});

Each event name becomes a camel-cased action creator (ProductsPageActions.searchChanged({ query })) with the type [Products Page] Search Changed. Two events with the same name in one group are a compile error, which removes a whole class of copy-paste bugs.

Reducer and selectors with createFeature #

createFeature takes a feature name and a reducer, and generates a selector for the feature and for every top-level property of its state:

import { createFeature, createReducer, createSelector, on } from '@ngrx/store';

interface ProductsState {
  products: Product[];
  query: string;
  loading: boolean;
  error: string | null;
}

const initialState: ProductsState = { products: [], query: '', loading: false, error: null };

export const productsFeature = createFeature({
  name: 'products',
  reducer: createReducer(
    initialState,
    on(ProductsPageActions.opened, state => ({ ...state, loading: true, error: null })),
    on(ProductsPageActions.searchChanged, (state, { query }) => ({ ...state, query })),
    on(ProductsApiActions.productsLoadedSuccess, (state, { products }) => ({
      ...state, products, loading: false,
    })),
    on(ProductsApiActions.productsLoadedFailure, (state, { error }) => ({
      ...state, error, loading: false,
    })),
  ),
  extraSelectors: ({ selectProducts, selectQuery }) => ({
    selectVisibleProducts: createSelector(selectProducts, selectQuery, (products, query) =>
      products.filter(p => p.name.toLowerCase().includes(query.toLowerCase())),
    ),
  }),
});

You get productsFeature.selectProductsState, selectProducts, selectQuery, selectLoading and selectError for free, plus the selectVisibleProducts you defined. Selectors built with createSelector are memoised: they recompute only when their input selectors return new values.

One restriction: createFeature can't generate selectors for optional properties, so write error: string | null rather than error?: string, and include every property in the initial state.

Register the feature where it's used, for example on a lazy route, so its code and state load with that route:

import { provideState } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import * as productsEffects from './products.effects';

export const routes: Routes = [
  {
    path: 'products',
    loadComponent: () => import('./products-page').then(m => m.ProductsPage),
    providers: [provideState(productsFeature), provideEffects(productsEffects)],
  },
];

Organising files and naming actions #

A feature usually ends up as four small files: products.actions.ts with the action groups, products.feature.ts with createFeature, products.effects.ts with functional effects, and the route file that registers them. Keep them next to the components that use them rather than in a global store/ folder; when the feature is deleted or moved, its state goes with it.

Action naming matters more than it looks. An action is a log entry, so name it after what happened and where: [Products Page] Opened and [Products API] Products Loaded Success, not [Products] Load. Event-style names let several reducers and effects respond to one action without the component knowing about them, and they make the DevTools log read like a story of the session. Avoid dispatching the same action from several places with different meanings; create one action per source instead, even if two of them trigger the same reducer case. on() accepts several action creators, so on(ProductsPageActions.opened, ProductsPageActions.refreshClicked, ...) handles both without duplicating logic.

Keep reducers pure and boring: no HTTP calls, no dates from new Date(), no random ids. Anything that depends on the outside world belongs in an effect, which passes the result into the reducer as action props.

Reading state as signals #

store.selectSignal(selector) returns a signal, so NgRx Store fits naturally into signal-based, OnPush components:

import { Component, inject } from '@angular/core';
import { Store } from '@ngrx/store';

@Component({
  selector: 'app-products-page',
  template: `
    <input [value]="query()" (input)="onSearch($any($event.target).value)" placeholder="Search" />
    @if (loading()) { <p>Loading…</p> }
    @for (product of products(); track product.id) {
      <app-product-card [product]="product" />
    }
  `,
  imports: [ProductCard],
})
export class ProductsPage {
  private store = inject(Store);

  protected query = this.store.selectSignal(productsFeature.selectQuery);
  protected loading = this.store.selectSignal(productsFeature.selectLoading);
  protected products = this.store.selectSignal(productsFeature.selectVisibleProducts);

  constructor() {
    this.store.dispatch(ProductsPageActions.opened());
  }

  onSearch(query: string): void {
    this.store.dispatch(ProductsPageActions.searchChanged({ query }));
  }
}

store.select() still returns an observable if you need RxJS operators on the result.

Functional effects #

Effects used to be classes. Today a plain exported function works, with dependencies injected as default parameters, which also makes them easy to test:

import { inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, exhaustMap, map, of, tap } from 'rxjs';

export const loadProducts = createEffect(
  (actions$ = inject(Actions), api = inject(ProductsService)) =>
    actions$.pipe(
      ofType(ProductsPageActions.opened),
      exhaustMap(() =>
        api.getAll().pipe(
          map(products => ProductsApiActions.productsLoadedSuccess({ products })),
          catchError((error: { message: string }) =>
            of(ProductsApiActions.productsLoadedFailure({ error: error.message })),
          ),
        ),
      ),
    ),
  { functional: true },
);

export const logLoadFailure = createEffect(
  () =>
    inject(Actions).pipe(
      ofType(ProductsApiActions.productsLoadedFailure),
      tap(({ error }) => console.error('Products failed to load:', error)),
    ),
  { functional: true, dispatch: false },
);

Two details matter. The flattening operator decides concurrency: exhaustMap ignores new opened actions while a load is running, switchMap cancels the previous request (right for search), and concatMap queues (right for saves that must happen in order). And catchError belongs inside the inner pipe; if it's on the outer stream, the first error completes the effect and it stops listening for good.

Register effects with provideEffects(...), passing classes or a module namespace of functional effects as in the route example above.

DevTools and runtime checks #

With provideStoreDevtools and the Redux DevTools browser extension, you can see every action with its payload, the state before and after, a diff, and you can jump back to any earlier state. maxAge limits how many actions are kept; logOnly: !isDevMode() keeps production builds from exposing time-travel controls.

In development, NgRx also runs runtime checks. Two are on by default: strictStateImmutability and strictActionImmutability, which throw if a reducer or component mutates state or actions. They are disabled automatically in production builds. Treat their errors as real bugs: a mutation that "works" today breaks memoised selectors and OnPush components later.

When NgRx Store is still the right choice #

Situation Why Store fits
You need a full history of what the user did (audit, support, bug reports) Actions are a serialisable event log by design
Several features must react to the same event One action, many reducers and effects, no direct coupling
Time-travel debugging is part of the team's workflow Built into @ngrx/store-devtools
A large existing codebase already uses it well Rewriting working state code rarely pays off
Complex async orchestration across features Effects compose actions and RxJS operators cleanly

If none of these apply, a Signal Store or a service with signals will be less code for the same result (lesson 14.1).

Migrating to Signal Store incrementally #

You don't need a big-bang rewrite. Both libraries can run side by side:

  1. Freeze the global store's scope. Keep existing features on NgRx Store; write new features as Signal Stores.
  2. Read global state from Signal Stores. A Signal Store can inject the global Store and expose selectors as signals:
export const CheckoutStore = signalStore(
  withState({ step: 1 }),
  withProps(() => ({ globalStore: inject(Store) })),
  withComputed(({ globalStore }) => ({
    cartItems: globalStore.selectSignal(selectCartItems),
  })),
);
  1. Move feature state that only one area uses. State that's read by one route and has no cross-feature effects is the easiest to move: create a Signal Store, switch the components over, then delete the actions, reducer and effects.
  2. Keep truly global, event-driven state in NgRx Store as long as it earns its keep. If you want Signal Store with an action-style flow, its Events plugin (@ngrx/signals/events) offers events, reducers and event handlers inside Signal Stores.

Gotchas #

Symptom Cause Fix
Effect stops working after the first error catchError on the outer actions$ stream Move catchError into the inner observable
Cannot assign to read only property in development Mutating state or an action payload Return new objects from reducers; copy before changing
Selector returns undefined on a lazy route Feature state not registered yet Add provideState(feature) to that route's providers
createFeature doesn't generate a selector for a property The property is optional (?) Make it required with a null union and include it in the initial state
Search shows results for an old query exhaustMap or mergeMap used for search switchMap in the effect
Nothing shows in Redux DevTools Extension missing or provideStoreDevtools not added Install the extension; add the provider in development

What's next #

Lesson 14.4 changes topic to forms: building your own form controls with ControlValueAccessor so they work with reactive and template-driven forms, and the simpler Signal Forms equivalent. Then 14.5 covers Angular Material and the CDK.

Try it yourself #

Pick one feature in an NgRx Store app and refactor it to the modern API: replace action constants with createActionGroup, the reducer and hand-written selectors with createFeature, async-piped observables with selectSignal, and any effect class with functional effects. Check the result in Redux DevTools.

YouOur “save order” effect sometimes saves the same order twice when users double-click the button, and occasionally saves an older version after a newer one. The effect uses mergeMap. What should it use?
Claude · used search_documentationmergeMap runs every save in parallel, so two clicks produce two requests that can finish in any order. You have two different problems. For the double-click, use exhaustMap: while a save is in flight, further saveClicked actions are ignored. If users can legitimately edit and save again while a save is running, use concatMap instead, which queues saves and runs them in order, so an older version can never land after a newer one. Don’t use switchMap for writes: cancelling the HTTP observable doesn’t cancel the request on the server. Also disable the button while selectSaving is true, and keep catchError inside the inner pipe so one failed save doesn’t stop the effect.

Up next in Angular

More from this topic

View all Angular articles →
Angular

When Angular is launched ?

Link copied Angular When Angular is launched ? February 8, 2024 · 1 min read When was Angular launched? # Angu…

Feb 8, 2024 Read →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *