Angular State Management with NgRx
Manage complex application state with NgRx — Store, Actions, Reducers, Selectors, and Effects. Includes the modern NgRx Signal Store for simpler use cases.
NgRx brings Redux-style state management to Angular. When your app has complex shared state, async data flows, or you need time-travel debugging, NgRx provides structure and predictability that simple services can't match.
When to Use NgRx
Use NgRx when:
- State is shared across many unrelated components
- Complex async flows with loading/error/success states
- You need optimistic updates with rollback
- Multiple data sources update the same state
Use a service with signals instead when:
- State is owned by a single feature
- Simple CRUD with a straightforward data flow
- Team is small and NgRx overhead isn't worth it
Install NgRx
ng add @ngrx/store
ng add @ngrx/effects
ng add @ngrx/store-devtools # optional — browser extension for time-travel debuggingCore Concepts
Action → describes what happened ("Load Products", "Add to Cart")
Reducer → produces the new state given action + old state (pure function)
Selector → reads a slice of state (memoized)
Effect → handles side effects — API calls, navigation, etc.
Store → the single source of truth, holds all stateStep 1 — Define State and Actions
// products/product.actions.ts
import { createAction, props } from '@ngrx/store';
import { Product } from '../models/product.model';
export const loadProducts = createAction('[Products] Load Products');
export const loadProductsSuccess = createAction('[Products] Load Success', props<{ products: Product[] }>());
export const loadProductsFailure = createAction('[Products] Load Failure', props<{ error: string }>());
export const addProduct = createAction('[Products] Add', props<{ product: Product }>());
export const deleteProduct = createAction('[Products] Delete', props<{ id: number }>());Step 2 — Define State and Reducer
// products/product.reducer.ts
import { createReducer, on } from '@ngrx/store';
import * as ProductActions from './product.actions';
import { Product } from '../models/product.model';
export interface ProductState {
products: Product[];
isLoading: boolean;
error: string | null;
}
export const initialState: ProductState = {
products: [],
isLoading: false,
error: null,
};
export const productReducer = createReducer(
initialState,
on(ProductActions.loadProducts, state => ({
...state,
isLoading: true,
error: null,
})),
on(ProductActions.loadProductsSuccess, (state, { products }) => ({
...state,
products,
isLoading: false,
})),
on(ProductActions.loadProductsFailure, (state, { error }) => ({
...state,
error,
isLoading: false,
})),
on(ProductActions.addProduct, (state, { product }) => ({
...state,
products: [...state.products, product],
})),
on(ProductActions.deleteProduct, (state, { id }) => ({
...state,
products: state.products.filter(p => p.id !== id),
})),
);Step 3 — Selectors
// products/product.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { ProductState } from './product.reducer';
export const selectProductState = createFeatureSelector<ProductState>('products');
export const selectAllProducts = createSelector(selectProductState, s => s.products);
export const selectIsLoading = createSelector(selectProductState, s => s.isLoading);
export const selectError = createSelector(selectProductState, s => s.error);
// Derived selectors (memoized — only recompute when inputs change)
export const selectActiveProducts = createSelector(
selectAllProducts,
products => products.filter(p => p.active),
);
export const selectProductById = (id: number) => createSelector(
selectAllProducts,
products => products.find(p => p.id === id),
);
export const selectProductCount = createSelector(
selectAllProducts,
products => products.length,
);Step 4 — Effects
Effects handle async operations triggered by actions:
// products/product.effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { inject } from '@angular/core';
import { switchMap, map, catchError, of } from 'rxjs';
import { ProductService } from '../services/product.service';
import * as ProductActions from './product.actions';
@Injectable()
export class ProductEffects {
private actions$ = inject(Actions);
private productService = inject(ProductService);
loadProducts$ = createEffect(() =>
this.actions$.pipe(
ofType(ProductActions.loadProducts),
switchMap(() =>
this.productService.getAll().pipe(
map(products => ProductActions.loadProductsSuccess({ products })),
catchError(error => of(ProductActions.loadProductsFailure({ error: error.message }))),
),
),
),
);
}Step 5 — Register in app.config.ts
import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { productReducer } from './products/product.reducer';
import { ProductEffects } from './products/product.effects';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideStore({ products: productReducer }),
provideEffects([ProductEffects]),
provideStoreDevtools({ maxAge: 25, logOnly: false }),
],
};Step 6 — Use in Components
import { Component, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { AsyncPipe } from '@angular/common';
import { inject } from '@angular/core';
import { loadProducts, deleteProduct } from './product.actions';
import { selectAllProducts, selectIsLoading, selectError } from './product.selectors';
@Component({
selector: 'app-product-list',
standalone: true,
imports: [AsyncPipe],
template: `
@if (isLoading$ | async) {
<p>Loading products...</p>
}
@if (error$ | async; as error) {
<p class="error">{{ error }}</p>
}
@for (product of products$ | async; track product.id) {
<div>
<h3>{{ product.name }}</h3>
<button (click)="delete(product.id)">Delete</button>
</div>
}
`,
})
export class ProductListComponent implements OnInit {
private store = inject(Store);
products$ = this.store.select(selectAllProducts);
isLoading$ = this.store.select(selectIsLoading);
error$ = this.store.select(selectError);
ngOnInit(): void {
this.store.dispatch(loadProducts());
}
delete(id: number): void {
this.store.dispatch(deleteProduct({ id }));
}
}NgRx Signal Store (Modern — Lighter Alternative)
For feature-level state, @ngrx/signals is much lighter than the full Store:
ng add @ngrx/signalsimport { signalStore, withState, withMethods, withComputed, patchState } from '@ngrx/signals';
import { computed } from '@angular/core';
import { inject } from '@angular/core';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { switchMap, tap } from 'rxjs';
export const ProductStore = signalStore(
{ providedIn: 'root' },
withState({
products: [] as Product[],
isLoading: false,
error: null as string | null,
}),
withComputed(({ products }) => ({
activeProducts: computed(() => products().filter(p => p.active)),
totalCount: computed(() => products().length),
})),
withMethods((store, productService = inject(ProductService)) => ({
loadProducts: rxMethod<void>(
switchMap(() => {
patchState(store, { isLoading: true });
return productService.getAll().pipe(
tap({
next: (products) => patchState(store, { products, isLoading: false }),
error: (err) => patchState(store, { error: err.message, isLoading: false }),
}),
);
}),
),
addProduct(product: Product): void {
patchState(store, { products: [...store.products(), product] });
},
removeProduct(id: number): void {
patchState(store, { products: store.products().filter(p => p.id !== id) });
},
})),
);Using in a component:
@Component({ /* ... */ })
export class ProductListComponent {
store = inject(ProductStore);
ngOnInit(): void {
this.store.loadProducts();
}
delete(id: number): void {
this.store.removeProduct(id);
}
}@if (store.isLoading()) { <p>Loading...</p> }
@for (product of store.activeProducts(); track product.id) {
<p>{{ product.name }}</p>
}
<p>Total: {{ store.totalCount() }}</p>When to Use Which
| Scenario | Recommendation | |---|---| | Simple feature with a few components | Service + Signals | | Feature with complex async flows | NgRx Signal Store | | App-wide shared state (auth, cart, notifications) | NgRx Store | | You already have NgRx in the project | Stick with NgRx Store |
Quick Reference
Install: ng add @ngrx/store @ngrx/effects
Actions: createAction('[Feature] Name', props<{ data: Type }>())
Reducer: createReducer(initialState, on(action, (state, props) => newState))
Selector: createFeatureSelector + createSelector
Effect: createEffect(() => actions$.pipe(ofType(...), switchMap(...)))
Register: provideStore({ key: reducer }), provideEffects([Effects])
Dispatch: store.dispatch(myAction({ id }))
Select: store.select(mySelector) → Observable
Signal Store:
signalStore + withState + withMethods + withComputed
patchState(store, { field: value })
store.field() ← signal access (not Observable)Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.