Classic NgRx Store: createActionGroup, createFeature, Selectors, Functional Effects and DevTools
The classic NgRx Store keeps state in a single global tree and changes it only through actions: createActionGroup defines actions, createFeature the reducer and generated selectors, createSelector memoized derived data, and functional effects side effects such as HTTP. We build Bookstore's back office orders with this model and show with real output NgRx 22's DevTools actionCreators option, the bugs the ESLint rules catch, and the extra values an unmemoized selector produces.
Version note: The examples in this post were run with Angular 22.2, NgRx 22.0.1 (@ngrx/store, @ngrx/effects, @ngrx/store-devtools, @ngrx/operators, @ngrx/eslint-plugin), ESLint 10.15, typescript-eslint 8.72, TypeScript 6.0 and Node.js 24 (Angular 22.2 projects use TypeScript 6.0); browser behaviour was observed with Playwright 1.65 in headless Chromium against the mock Bookstore API in the snapshot.
Predict First
We are writing an orders page for the back office of Bookstore, the online bookstore used as the shared example across this site's series; this time the state lives in the classic NgRx Store. The page reads the filtered order list like this; selectSignal returns a selector as a signal:
// wrong: an inline selector, filter() returns a new array for every action
protected readonly orders = this.#store.selectSignal((state) => {
const status = adminOrdersFeature.selectStatusFilter(state);
const orders = adminOrdersFeature.selectOrders(state);
return status === 'all' ? orders : orders.filter((o) => o.status === status);
});
With the filter on "new", the user clicks the "Ship" button of #1041. That dispatches two actions: first Ship Clicked (only the list of orders being shipped changes in the state), then, when the API answers, Order Shipped (the order's status changes). How many times does the orders signal produce a new value for these two actions, and does that number change with a selector written with createSelector? Write down your prediction; the answer is in "Common Pitfalls".
In the previous posts we worked with Signal Store. The classic NgRx Store is the original form of the Redux model in NgRx: a single global state tree, actions as the only way to change state, pure functions that make the state transitions (reducers), memoized functions that derive data from state (selectors), and effects that run side effects. The model itself is covered in the UI Architecture Patterns series post "Flux and Unidirectional Data Flow: Why Didn't Two-Way Binding Scale?"; here we write it with today's API: createActionGroup, createFeature, selectors, functional effects, Store DevTools and NgRx's ESLint rules. Installation:
npm install @ngrx/store @ngrx/effects @ngrx/store-devtools
Actions: createActionGroup
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import type { AdminOrder, OrderStatus } from '../models';
// Actions describe events from one source; the type becomes '[Admin Orders Page] Opened'
export const adminOrdersPageActions = createActionGroup({
source: 'Admin Orders Page',
events: {
Opened: emptyProps(),
'Status Filter Changed': props<{ status: OrderStatus | 'all' }>(),
'Ship Clicked': props<{ id: number }>(),
},
});
export const adminOrdersApiActions = createActionGroup({
source: 'Admin Orders API',
events: {
'Orders Loaded': props<{ orders: AdminOrder[] }>(),
'Orders Load Failed': props<{ error: string }>(),
'Order Shipped': props<{ order: AdminOrder }>(),
'Order Ship Failed': props<{ id: number; error: string }>(),
},
});
createActionGroup defines action creators from the same source together; the action type, such as [Admin Orders Page] Status Filter Changed, is made of the source and the event name. Event names are written as readable phrases, and the creators are converted to camelCase: adminOrdersPageActions.statusFilterChanged({ status }). A payload is defined with props<...>(), an action without a payload with emptyProps(). It is almost the same as the Signal Store eventGroup from the previous post; the difference is that there the type is produced from the key itself, in the form [Checkout Page] orderSubmitted, with the data in a payload field, while here the props are fields of the action itself.
There are two groups because there are two sources: what the user does on the page and what the API answers. This split answers the question "did this change come from the user or from the server?" when you read the action log.
createFeature: Reducer and Selectors
import { createFeature, createReducer, createSelector, on } from '@ngrx/store';
import type { AdminOrder, OrderStatus } from '../models';
import { adminOrdersApiActions, adminOrdersPageActions } from './orders.actions';
interface AdminOrdersState {
orders: AdminOrder[];
statusFilter: OrderStatus | 'all';
loading: boolean;
shippingIds: number[];
error: string | null;
}
const initialState: AdminOrdersState = { orders: [], statusFilter: 'all', loading: false, shippingIds: [], error: null };
// Feature = name + reducer + generated selectors (selectOrders, selectStatusFilter, ...)
export const adminOrdersFeature = createFeature({
name: 'adminOrders',
// explicit return types: the compiler rejects a misspelled or unknown state key
reducer: createReducer(
initialState,
on(adminOrdersPageActions.opened, (state): AdminOrdersState => ({ ...state, loading: true, error: null })),
on(adminOrdersPageActions.statusFilterChanged, (state, { status }): AdminOrdersState => ({ ...state, statusFilter: status })),
on(adminOrdersPageActions.shipClicked, (state, { id }): AdminOrdersState => ({ ...state, shippingIds: [...state.shippingIds, id] })),
on(adminOrdersApiActions.ordersLoaded, (state, { orders }): AdminOrdersState => ({ ...state, orders, loading: false })),
on(adminOrdersApiActions.ordersLoadFailed, (state, { error }): AdminOrdersState => ({ ...state, loading: false, error })),
on(adminOrdersApiActions.orderShipped, (state, { order }): AdminOrdersState => ({
...state,
orders: state.orders.map((o) => (o.id === order.id ? order : o)),
shippingIds: state.shippingIds.filter((id) => id !== order.id),
})),
on(adminOrdersApiActions.orderShipFailed, (state, { id, error }): AdminOrdersState => ({
...state,
shippingIds: state.shippingIds.filter((x) => x !== id),
error,
})),
),
// Derived data: memoized, recomputed only when an input selector returns a new value
extraSelectors: ({ selectOrders, selectStatusFilter }) => ({
selectVisibleOrders: createSelector(selectOrders, selectStatusFilter, (orders, status) =>
status === 'all' ? orders : orders.filter((o) => o.status === status),
),
selectNewCount: createSelector(selectOrders, (orders) => orders.filter((o) => o.status === 'new').length),
}),
});
createReducer binds actions to state transitions with on(...); each transition returns a new object without changing the old state. createFeature gathers the feature name, the reducer and the selectors into a single object: a selector is generated for every state field (selectOrders, selectStatusFilter, selectShippingIds…), and derived selectors are added with extraSelectors. createFeature arrived in NgRx 12.1 and extraSelectors in 15.2; before that, each of these was a separate createFeatureSelector and createSelector line.
selectVisibleOrders and selectNewCount are memoized: according to the NgRx documentation, createSelector remembers its last arguments and, as long as the input selectors return the same values, returns the last result without calling the projector function again. So the "new orders only" list is not recomputed when shippingIds changes; it is recomputed when orders or statusFilter change. The memoized value stays in memory; for a selector holding a large result you can clear it with release().
Notice that the return type of every on function in the reducer is written explicitly as AdminOrdersState; the reason is in the "ESLint Rules" section.
Functional Effects and Route-Level Registration
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { mapResponse } from '@ngrx/operators';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { exhaustMap, mergeMap } from 'rxjs';
import { apiUrl } from '../api';
import type { AdminOrder } from '../models';
import { adminOrdersApiActions, adminOrdersPageActions } from './orders.actions';
// Functional effects: no class, dependencies as default parameters
export const loadOrders = createEffect(
(actions$ = inject(Actions), http = inject(HttpClient)) => {
return actions$.pipe(
ofType(adminOrdersPageActions.opened),
exhaustMap(() =>
http.get<AdminOrder[]>(`${apiUrl}/orders`).pipe(
mapResponse({
next: (orders) => adminOrdersApiActions.ordersLoaded({ orders }),
error: (error: HttpErrorResponse) => adminOrdersApiActions.ordersLoadFailed({ error: `HTTP ${error.status}` }),
}),
),
),
);
},
{ functional: true },
);
export const shipOrder = createEffect(
(actions$ = inject(Actions), http = inject(HttpClient)) => {
return actions$.pipe(
ofType(adminOrdersPageActions.shipClicked),
// different orders can be shipped in parallel
mergeMap(({ id }) =>
http.post<AdminOrder>(`${apiUrl}/orders/${id}/ship`, {}).pipe(
mapResponse({
next: (order) => adminOrdersApiActions.orderShipped({ order }),
error: (error: HttpErrorResponse) =>
adminOrdersApiActions.orderShipFailed({ id, error: `HTTP ${error.status}` }),
}),
),
),
);
},
{ functional: true },
);
Since NgRx 15.2 effects do not need a class: createEffect receives a function and { functional: true }, and the dependencies come in as default parameters with inject(). That also makes it easy to pass dependencies as arguments in tests (ninth post). An effect filters the actions it cares about with ofType and returns an Observable of actions; every returned action is dispatched to the store. Error handling is the same as in the previous two posts: mapResponse on the inner stream, so a failed request does not stop the effect.
The choice of operator is a business rule here too. exhaustMap ignores a second Opened that arrives while the list is loading; shipping different orders is independent work, so it runs in parallel with mergeMap.
The store is provided once at the application level, while the feature state and effects are in the providers of the back office route:
// Classic NgRx Store next to the Signal Stores; feature state is registered by the admin route
provideStore({}, { metaReducers: isDevMode() ? [logActions] : [] }),
provideStoreDevtools({
maxAge: 25,
logOnly: !isDevMode(),
// NgRx 22: these actions can be dispatched from the Redux DevTools extension
actionCreators: adminOrdersPageActions,
}),
{
path: 'admin/orders',
component: AdminOrdersPage,
// the back office feature registers its state and effects when the route is first activated
providers: [provideState(adminOrdersFeature), provideEffects(adminOrdersEffects)],
},
The customer side runs without this state at all; the adminOrders state and the effects are registered when the user first enters /admin/orders. The logActions passed to provideStore is a meta-reducer: the NgRx documentation describes meta-reducers as a layer that steps in before actions reach the normal reducers. Ours only writes the type of every action in development mode:
import type { ActionReducer } from '@ngrx/store';
// Meta-reducer: wraps every reducer; here it only writes the action type to the console
export function logActions<S>(reducer: ActionReducer<S>): ActionReducer<S> {
return (state, action) => {
console.log(`[action] ${action.type}`);
return reducer(state, action);
};
}
selectSignal in the Component
import { Component, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import type { OrderStatus } from '../models';
import { adminOrdersPageActions } from './orders.actions';
import { adminOrdersFeature } from './orders.feature';
@Component({
selector: 'app-admin-orders-page',
template: `
<h1>Back office: orders ({{ newCount() }} new)</h1>
<select class="status-filter" [value]="statusFilter()" (change)="filter($any($event.target).value)">
<option value="all">All</option>
<option value="new">New</option>
<option value="shipped">Shipped</option>
</select>
@if (error(); as error) {
<p class="admin-error">{{ error }}</p>
}
<ul class="admin-orders">
@for (order of orders(); track order.id) {
<li>
#{{ order.id }} {{ order.customer }} - {{ order.total }} TRY - {{ order.status }}
@if (order.status === 'new') {
<button class="ship" [disabled]="shippingIds().includes(order.id)" (click)="ship(order.id)">Ship</button>
}
</li>
}
</ul>
`,
})
export class AdminOrdersPage {
readonly #store = inject(Store);
// selectSignal: a selector read as a signal
protected readonly orders = this.#store.selectSignal(adminOrdersFeature.selectVisibleOrders);
protected readonly newCount = this.#store.selectSignal(adminOrdersFeature.selectNewCount);
protected readonly statusFilter = this.#store.selectSignal(adminOrdersFeature.selectStatusFilter);
protected readonly shippingIds = this.#store.selectSignal(adminOrdersFeature.selectShippingIds);
protected readonly error = this.#store.selectSignal(adminOrdersFeature.selectError);
constructor() {
this.#store.dispatch(adminOrdersPageActions.opened());
}
protected filter(status: OrderStatus | 'all'): void {
this.#store.dispatch(adminOrdersPageActions.statusFilterChanged({ status }));
}
protected ship(id: number): void {
this.#store.dispatch(adminOrdersPageActions.shipClicked({ id }));
}
}
store.selectSignal(selector) (since NgRx 16) returns a selector as an Angular signal; the template reads it as orders(), with no async pipe or subscription. The component does not change state; it only dispatches actions: opened() when the page opens, statusFilterChanged for the filter, shipClicked for the button.
Store DevTools and actionCreators
provideStoreDevtools connects the store to the Redux DevTools browser extension: every action and the state after it are listed in the extension, and you can go back to an earlier action and see the app in that state (time travel). maxAge: 25 limits the number of actions kept in the history; the NgRx documentation says this is critical for performance and that the default is unlimited. The actionCreators option, new in NgRx 22, lists the given creators in the extension's dispatch panel; when an action group is given, the creators appear under their keys.
Since we could not install the extension in a headless browser, we used a small stub that mimics the window.__REDUX_DEVTOOLS_EXTENSION__ API the extension adds to the page (connect, send, subscribe) and writes what it receives to the console; the extension's own UI and time travel were not run for this post. Opening the page, filtering, shipping #1041 and finally dispatching shipClicked({ id: 1042 }) "from the extension":
0.1 s console: [devtools] connect maxAge=25 actionCreators: opened(props), statusFilterChanged(props), shipClicked(props)
0.1 s console: [action] @ngrx/store/init
0.1 s console: [action] @ngrx/store/update-reducers
0.1 s console: [action] @ngrx/store-devtools/recompute
0.1 s console: [action] @ngrx/store-devtools/recompute
0.1 s console: [action] @ngrx/effects/init
0.1 s console: [devtools] @ngrx/effects/init | state: 0 orders, filter=all, shipping=[]
0.1 s console: [action] [Admin Orders Page] Opened
0.1 s console: [devtools] [Admin Orders Page] Opened | state: 0 orders, filter=all, shipping=[]
0.4 s console: [action] [Admin Orders API] Orders Loaded
0.4 s console: [devtools] [Admin Orders API] Orders Loaded | state: 3 orders, filter=all, shipping=[]
opened: Back office: orders (2 new) | #1040 Ada - 490 TRY - shipped | #1041 Alan - 640 TRY - new Ship | #1042 Grace - 430 TRY - new Ship
1.2 s console: [action] [Admin Orders Page] Status Filter Changed
1.2 s console: [devtools] [Admin Orders Page] Status Filter Changed | state: 3 orders, filter=new, shipping=[]
filter=new: Back office: orders (2 new) | #1041 Alan - 640 TRY - new Ship | #1042 Grace - 430 TRY - new Ship
1.5 s console: [action] [Admin Orders Page] Ship Clicked
1.5 s console: [devtools] [Admin Orders Page] Ship Clicked | state: 3 orders, filter=new, shipping=[1041]
1.8 s console: [action] [Admin Orders API] Order Shipped
1.8 s console: [devtools] [Admin Orders API] Order Shipped | state: 3 orders, filter=new, shipping=[]
ship #1041: Back office: orders (1 new) | #1042 Grace - 430 TRY - new Ship
2.4 s console: [action] [Admin Orders Page] Ship Clicked
2.4 s console: [devtools] [Admin Orders Page] Ship Clicked | state: 3 orders, filter=new, shipping=[1042]
2.7 s console: [action] [Admin Orders API] Order Shipped
2.7 s console: [devtools] [Admin Orders API] Order Shipped | state: 3 orders, filter=new, shipping=[]
devtools dispatch: Back office: orders (0 new) |
When the connection is made, the three creators and their parameters go to the extension (it says props for opened too because NgRx extracts parameter names from the source of the creator function). Each user action is followed by the meta-reducer's line and then by the action and state sent to the extension. The last Ship Clicked came not from a button but from the { selected: 2, args: ['{ id: 1042 }'] } message the stub sent on behalf of the extension: NgRx 22 turned that message into the third creator in actionCreators (shipClicked) and dispatched it, and the effect sent the request just like for a real click. The counter in the heading went 2 → 1 → 0.
@ngrx/store/init, @ngrx/store/update-reducers and @ngrx/effects/init at startup are NgRx's own actions; update-reducers arrives when the route's provideState adds the feature reducer.
ESLint Rules
@ngrx/eslint-plugin contains rules that catch NgRx-specific mistakes. With flat config, the store, effects, signals and operators rule sets:
// NgRx lint rules for the store code (npm run lint)
import ngrx from '@ngrx/eslint-plugin';
import tseslint from 'typescript-eslint';
export default tseslint.config({
files: ['src/**/*.ts'],
languageOptions: { parser: tseslint.parser },
extends: [...ngrx.configs.store, ...ngrx.configs.effects, ...ngrx.configs.signals, ...ngrx.configs.operators],
});
npm run lint for the first version of the code (before the explicit return types and block bodies were added):
> bookstore-web@0.0.0 lint
> eslint src
src/app/admin/orders.effects.ts
13:5 error The callback of `Effect` should be wrapped in a block statement @ngrx/prefer-effect-callback-in-block-statement
29:5 error The callback of `Effect` should be wrapped in a block statement @ngrx/prefer-effect-callback-in-block-statement
src/app/admin/orders.feature.ts
20:39 error `On` functions should have an explicit return type when using arrow functions: `on(action, (state): State => {}` @ngrx/on-function-explicit-return-type
21:52 error `On` functions should have an explicit return type when using arrow functions: `on(action, (state): State => {}` @ngrx/on-function-explicit-return-type
22:44 error `On` functions should have an explicit return type when using arrow functions: `on(action, (state): State => {}` @ngrx/on-function-explicit-return-type
23:44 error `On` functions should have an explicit return type when using arrow functions: `on(action, (state): State => {}` @ngrx/on-function-explicit-return-type
24:48 error `On` functions should have an explicit return type when using arrow functions: `on(action, (state): State => {}` @ngrx/on-function-explicit-return-type
25:44 error `On` functions should have an explicit return type when using arrow functions: `on(action, (state): State => {}` @ngrx/on-function-explicit-return-type
30:47 error `On` functions should have an explicit return type when using arrow functions: `on(action, (state): State => {}` @ngrx/on-function-explicit-return-type
src/app/shared/with-request-log.ts
6:10 error Add an unused generic type to the function creating the signal store feature @ngrx/signal-store-feature-should-use-generic-type
✖ 10 problems (10 errors, 0 warnings)
3 errors and 0 warnings potentially fixable with the `--fix` option.
Three rules say three different things. prefer-effect-callback-in-block-statement is a style rule. on-function-explicit-return-type, on the other hand, prevents a real bug: a misspelled field such as (state) => ({ ...state, loding: true }) does not trigger TypeScript's excess property check when no return type is written and compiles silently; with the return type written:
✘ [ERROR] TS2561: Object literal may only specify known properties, but 'loding' does not exist in type 'AdminOrdersState'. Did you mean to write 'loading'? [plugin angular-compiler]
src/app/admin/orders.feature.ts:21:80:
21 │ ...): AdminOrdersState => ({ ...state, loding: true, error: null })),
╵ ~~~~~~
Without the return type, ng build completed without errors for the same line; loading would never become true, and you would only notice on screen. The third one, signal-store-feature-should-use-generic-type, found withRequestLog from the sixth post: a custom feature with an input but no generic parameter. There we described the known TypeScript issue from the NgRx documentation; the rule asks for an unused generic on the feature to prevent that same issue. The fix is withRequestLog<_>(name: string); after the three fixes npm run lint reports no problems.
Side by Side with Signal Store
At the end of this post two NgRx approaches work together in Bookstore: the cart, search, recently viewed and customer orders are in Signal Store; back office orders are in the classic Store. They do not get in each other's way because they are different mechanisms: classic Store actions do not reach a Signal Store's withReducer, and Signal Store events do not reach classic reducers. In a real project the migration usually goes feature by feature: new features are written with Signal Store, old ones are moved as they are touched. If two features need to react to the same occurrence, you need a bridge, for example listening to inject(Actions).pipe(ofType(...)) with rxMethod in a Signal Store. Keep such bridges few and visible.
When does the classic Store still make sense? If the codebase is already written with it and works well, the payoff of migrating rarely covers its cost. It is also a strong option in a large team that wants every state change logged as an action, time travel in Redux DevTools, and a strict "state only changes in reducers" discipline. For a new small or medium feature, Signal Store does the same job with fewer files; that comparison is the subject of the tenth post.
In Production
Store DevTools can run in production too; logOnly: !isDevMode() puts the extension into log-only mode in production (features such as dispatch and time travel are off). Since any user with the extension can see the state, if the state contains something like a token, personal data or an internal pricing rule, mask it with actionSanitizer and stateSanitizer, or leave DevTools out of the production build entirely. Keep maxAge small: a copy of the state is kept with every action.
The logActions meta-reducer is added only when isDevMode() is true; heavy work in a meta-reducer that runs on every action slows down every click in the app. Registering feature state at the route level is also the first step to keeping back office code out of the customer bundle; if you move the route and its providers into a separate routes file with loadChildren, the reducer, the effects and the page load together in a separate chunk (we did not do that in this post).
Keeping server state in a reducer, as in this post, is a common pattern with the classic Store; but the warning from the first post applies: it is a copy and it can go stale. Fetching the list again with Opened every time the page opens is a simple solution; if the list stays open for a long time, think about the refresh strategy explicitly.
Common Pitfalls
Returning a new array from a selector every time
With the inline selector from "Predict First", we added a temporary effect that logs every change of the orders signal and ran the same scenario:
0.1 s console: [action] [Admin Orders Page] Opened
0.1 s console: [page] orders signal changed: 0 orders
0.4 s console: [action] [Admin Orders API] Orders Loaded
0.4 s console: [page] orders signal changed: 3 orders
1.2 s console: [action] [Admin Orders Page] Status Filter Changed
1.2 s console: [page] orders signal changed: 2 orders
1.5 s console: [action] [Admin Orders Page] Ship Clicked
1.5 s console: [page] orders signal changed: 2 orders
1.8 s console: [action] [Admin Orders API] Order Shipped
1.8 s console: [page] orders signal changed: 1 orders
And with adminOrdersFeature.selectVisibleOrders:
0.1 s console: [action] [Admin Orders Page] Opened
0.1 s console: [page] orders signal changed: 0 orders
0.4 s console: [action] [Admin Orders API] Orders Loaded
0.4 s console: [page] orders signal changed: 3 orders
1.2 s console: [action] [Admin Orders Page] Status Filter Changed
1.2 s console: [page] orders signal changed: 2 orders
1.5 s console: [action] [Admin Orders Page] Ship Clicked
1.8 s console: [action] [Admin Orders API] Order Shipped
1.8 s console: [page] orders signal changed: 1 orders
With the inline selector, Ship Clicked produced a new value as well: the list had the same content (2 orders), but because filter returns a new array on every call, the signal counted it as a change and everything depending on it ran again. With the memoized selector Ship Clicked went by quietly, because its inputs (orders and statusFilter) had not changed. Here the difference is one effect run, but on a page showing a large list and receiving many actions it means a recomputed list and change detection on every action. Define derived data in createSelector or extraSelectors.
Putting everything in the global store
Writing actions, a reducer and a selector for whether a dialog is open, the current value of an input, or a tab selection used by a single component is the classic Store version of the "moving state higher than needed" mistake from the first post. That state should stay in the component; the store is for state read from several places whose change history matters.
Catching the error on the outer stream of an effect
Putting catchError or mapResponse outside exhaustMap, at the end of actions$.pipe(...), makes the effect stop listening to actions after the first error; the same as the rxMethod pitfall from the sixth post. Error handling always goes on the inner, HTTP stream.
Using an action like a command
[Admin Orders Page] Opened instead of [Admin Orders Page] Load Orders: an action describes what happened. Another reducer or effect may react to the same action later (for example an analytics effect counting page opens); a command name makes that awkward.
A Small Experiment
★☆☆ Use selectNewCount to add a "2 new orders" badge for the back office to the header, and watch the badge go down after you ship an order. The badge must not appear on customer pages; how do you make sure of that?
★★☆ Add a stateSanitizer to Store DevTools that replaces customer names in adminOrders.orders with "***". Using the stub from this post, verify that the names are masked in the state sent to the extension and unchanged in the app's own state.
★★★ Write a bridge: when the Signal Store event ordersApiEvents.orderPlaced arrives, dispatch a [Checkout] Order Placed action to the classic Store and let the back office reducer add the new order to the list. Which side should the bridge live on, and what changes if one of the two sides is removed one day?
In the next post we move on to tests for the state code we have written so far: unit tests with Vitest for signal-based services, Signal Store, and the reducers, selectors and effects from this post.