NgRx Overview
Estimated Time: 4 hours | Difficulty: Advanced | Prerequisites: RxJS, Services, Signals
Copy
┌─────────────────────────────────────────────────────────────────────────┐
│ NgRx Architecture Flow │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Component │ ──────► │ Action │ ──────► │ Reducer │ │
│ │ │ dispatch│ │ │ │ │
│ └─────────────┘ └─────────────┘ └──────┬──────┘ │
│ ▲ │ │ │
│ │ │ ▼ │
│ │ │ ┌─────────────┐ │
│ │ │ │ Store │ │
│ │ │ │ (State) │ │
│ │ │ └──────┬──────┘ │
│ │ │ │ │
│ │ ▼ │ │
│ ┌─────────────┐ ┌─────────────┐ │ │
│ │ Selector │ ◄────── │ Store │ ◄─────────────┘ │
│ │ │ select │ (State) │ │
│ └─────────────┘ └─────────────┘ │
│ │ │ │
│ │ │ side effects │
│ │ ▼ │
│ │ ┌─────────────┐ ┌─────────────┐ │
│ └────────────────│ Effects │ ──────► │ Service │ │
│ │ │ API │ │ │
│ └─────────────┘ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Setting Up NgRx
Copy
# Install NgRx packages
npm install @ngrx/store @ngrx/effects @ngrx/entity @ngrx/store-devtools
# Optional: Component Store for local state
npm install @ngrx/component-store
# Generate store scaffolding
ng add @ngrx/store
ng add @ngrx/effects
ng add @ngrx/store-devtools
Configure Store
Copy
// app.config.ts
import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { isDevMode } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [
provideStore({
// Root reducers
auth: authReducer,
ui: uiReducer
}),
provideEffects([
AuthEffects,
NotificationEffects
]),
provideStoreDevtools({
maxAge: 25,
logOnly: !isDevMode(),
autoPause: true,
trace: true,
traceLimit: 75
}),
// ... other providers
]
};
Actions
Actions describe unique events in your application.Copy
// store/products/products.actions.ts
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import { Product, ProductFilters } from '../../models/product.model';
export const ProductsActions = createActionGroup({
source: 'Products',
events: {
// Load products
'Load Products': props<{ filters?: ProductFilters }>(),
'Load Products Success': props<{ products: Product[] }>(),
'Load Products Failure': props<{ error: string }>(),
// Single product
'Load Product': props<{ id: string }>(),
'Load Product Success': props<{ product: Product }>(),
'Load Product Failure': props<{ error: string }>(),
// CRUD operations
'Create Product': props<{ product: Omit<Product, 'id'> }>(),
'Create Product Success': props<{ product: Product }>(),
'Create Product Failure': props<{ error: string }>(),
'Update Product': props<{ id: string; changes: Partial<Product> }>(),
'Update Product Success': props<{ product: Product }>(),
'Update Product Failure': props<{ error: string }>(),
'Delete Product': props<{ id: string }>(),
'Delete Product Success': props<{ id: string }>(),
'Delete Product Failure': props<{ error: string }>(),
// UI actions
'Select Product': props<{ id: string | null }>(),
'Set Filters': props<{ filters: ProductFilters }>(),
'Clear Filters': emptyProps(),
// Optimistic updates
'Optimistic Update': props<{ id: string; changes: Partial<Product> }>(),
'Rollback Update': props<{ id: string; original: Product }>()
}
});
// Cart actions
export const CartActions = createActionGroup({
source: 'Cart',
events: {
'Add Item': props<{ productId: string; quantity: number }>(),
'Remove Item': props<{ productId: string }>(),
'Update Quantity': props<{ productId: string; quantity: number }>(),
'Clear Cart': emptyProps(),
'Load Cart': emptyProps(),
'Load Cart Success': props<{ items: CartItem[] }>(),
'Checkout': emptyProps(),
'Checkout Success': props<{ orderId: string }>(),
'Checkout Failure': props<{ error: string }>()
}
});
Reducers
Reducers are pure functions that handle state transitions.Copy
// store/products/products.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';
import { Product, ProductFilters } from '../../models/product.model';
import { ProductsActions } from './products.actions';
// Entity adapter for normalized state
export const adapter: EntityAdapter<Product> = createEntityAdapter<Product>({
selectId: (product) => product.id,
sortComparer: (a, b) => a.name.localeCompare(b.name)
});
export interface ProductsState extends EntityState<Product> {
selectedId: string | null;
loading: boolean;
error: string | null;
filters: ProductFilters;
totalCount: number;
}
export const initialState: ProductsState = adapter.getInitialState({
selectedId: null,
loading: false,
error: null,
filters: {},
totalCount: 0
});
export const productsReducer = createReducer(
initialState,
// Load products
on(ProductsActions.loadProducts, (state) => ({
...state,
loading: true,
error: null
})),
on(ProductsActions.loadProductsSuccess, (state, { products }) =>
adapter.setAll(products, {
...state,
loading: false,
totalCount: products.length
})
),
on(ProductsActions.loadProductsFailure, (state, { error }) => ({
...state,
loading: false,
error
})),
// Single product
on(ProductsActions.loadProductSuccess, (state, { product }) =>
adapter.upsertOne(product, state)
),
// Create
on(ProductsActions.createProductSuccess, (state, { product }) =>
adapter.addOne(product, {
...state,
totalCount: state.totalCount + 1
})
),
// Update
on(ProductsActions.updateProductSuccess, (state, { product }) =>
adapter.updateOne(
{ id: product.id, changes: product },
state
)
),
// Optimistic update
on(ProductsActions.optimisticUpdate, (state, { id, changes }) =>
adapter.updateOne({ id, changes }, state)
),
on(ProductsActions.rollbackUpdate, (state, { id, original }) =>
adapter.updateOne({ id, changes: original }, state)
),
// Delete
on(ProductsActions.deleteProductSuccess, (state, { id }) =>
adapter.removeOne(id, {
...state,
totalCount: state.totalCount - 1,
selectedId: state.selectedId === id ? null : state.selectedId
})
),
// Selection
on(ProductsActions.selectProduct, (state, { id }) => ({
...state,
selectedId: id
})),
// Filters
on(ProductsActions.setFilters, (state, { filters }) => ({
...state,
filters: { ...state.filters, ...filters }
})),
on(ProductsActions.clearFilters, (state) => ({
...state,
filters: {}
}))
);
Selectors
Selectors are pure functions for deriving and composing state.Copy
// store/products/products.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { ProductsState, adapter } from './products.reducer';
// Feature selector
export const selectProductsState = createFeatureSelector<ProductsState>('products');
// Entity adapter selectors
const { selectIds, selectEntities, selectAll, selectTotal } = adapter.getSelectors();
// Basic selectors
export const selectAllProducts = createSelector(
selectProductsState,
selectAll
);
export const selectProductEntities = createSelector(
selectProductsState,
selectEntities
);
export const selectProductIds = createSelector(
selectProductsState,
selectIds
);
export const selectProductsLoading = createSelector(
selectProductsState,
(state) => state.loading
);
export const selectProductsError = createSelector(
selectProductsState,
(state) => state.error
);
export const selectSelectedProductId = createSelector(
selectProductsState,
(state) => state.selectedId
);
export const selectProductFilters = createSelector(
selectProductsState,
(state) => state.filters
);
// Computed selectors
export const selectSelectedProduct = createSelector(
selectProductEntities,
selectSelectedProductId,
(entities, selectedId) => selectedId ? entities[selectedId] ?? null : null
);
export const selectProductById = (id: string) => createSelector(
selectProductEntities,
(entities) => entities[id] ?? null
);
// Filtered products
export const selectFilteredProducts = createSelector(
selectAllProducts,
selectProductFilters,
(products, filters) => {
let filtered = [...products];
if (filters.category) {
filtered = filtered.filter(p => p.category === filters.category);
}
if (filters.minPrice !== undefined) {
filtered = filtered.filter(p => p.price >= filters.minPrice!);
}
if (filters.maxPrice !== undefined) {
filtered = filtered.filter(p => p.price <= filters.maxPrice!);
}
if (filters.search) {
const search = filters.search.toLowerCase();
filtered = filtered.filter(p =>
p.name.toLowerCase().includes(search) ||
p.description?.toLowerCase().includes(search)
);
}
return filtered;
}
);
// Grouped products
export const selectProductsByCategory = createSelector(
selectAllProducts,
(products) => {
return products.reduce((acc, product) => {
const category = product.category || 'Uncategorized';
if (!acc[category]) {
acc[category] = [];
}
acc[category].push(product);
return acc;
}, {} as Record<string, Product[]>);
}
);
// Statistics
export const selectProductStats = createSelector(
selectAllProducts,
(products) => ({
total: products.length,
avgPrice: products.length
? products.reduce((sum, p) => sum + p.price, 0) / products.length
: 0,
categories: [...new Set(products.map(p => p.category))].length,
inStock: products.filter(p => p.stock > 0).length
})
);
// View model selector (combines multiple selectors)
export const selectProductsViewModel = createSelector(
selectFilteredProducts,
selectProductsLoading,
selectProductsError,
selectProductFilters,
selectProductStats,
(products, loading, error, filters, stats) => ({
products,
loading,
error,
filters,
stats,
hasFilters: Object.keys(filters).length > 0
})
);
Effects
Effects handle side effects like API calls.Copy
// store/products/products.effects.ts
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import {
catchError,
map,
mergeMap,
switchMap,
withLatestFrom,
tap,
exhaustMap,
concatMap
} from 'rxjs/operators';
import { of } from 'rxjs';
import { ProductsActions } from './products.actions';
import { ProductService } from '../../services/product.service';
import { ToastService } from '../../services/toast.service';
import { selectProductById } from './products.selectors';
@Injectable()
export class ProductsEffects {
private actions$ = inject(Actions);
private store = inject(Store);
private productService = inject(ProductService);
private toast = inject(ToastService);
// Load products - switchMap cancels previous request
loadProducts$ = createEffect(() =>
this.actions$.pipe(
ofType(ProductsActions.loadProducts),
switchMap(({ filters }) =>
this.productService.getProducts(filters).pipe(
map(products => ProductsActions.loadProductsSuccess({ products })),
catchError(error => of(ProductsActions.loadProductsFailure({
error: error.message
})))
)
)
)
);
// Load single product
loadProduct$ = createEffect(() =>
this.actions$.pipe(
ofType(ProductsActions.loadProduct),
mergeMap(({ id }) =>
this.productService.getProduct(id).pipe(
map(product => ProductsActions.loadProductSuccess({ product })),
catchError(error => of(ProductsActions.loadProductFailure({
error: error.message
})))
)
)
)
);
// Create product - exhaustMap ignores new requests while one is pending
createProduct$ = createEffect(() =>
this.actions$.pipe(
ofType(ProductsActions.createProduct),
exhaustMap(({ product }) =>
this.productService.createProduct(product).pipe(
map(created => ProductsActions.createProductSuccess({ product: created })),
catchError(error => of(ProductsActions.createProductFailure({
error: error.message
})))
)
)
)
);
// Update product with optimistic update
updateProduct$ = createEffect(() =>
this.actions$.pipe(
ofType(ProductsActions.updateProduct),
withLatestFrom(this.store),
mergeMap(([{ id, changes }, store]) => {
// Get original for rollback
const original = selectProductById(id)(store);
// Optimistic update
this.store.dispatch(ProductsActions.optimisticUpdate({ id, changes }));
return this.productService.updateProduct(id, changes).pipe(
map(product => ProductsActions.updateProductSuccess({ product })),
catchError(error => {
// Rollback on error
if (original) {
this.store.dispatch(ProductsActions.rollbackUpdate({ id, original }));
}
return of(ProductsActions.updateProductFailure({ error: error.message }));
})
);
})
)
);
// Delete product - concatMap maintains order
deleteProduct$ = createEffect(() =>
this.actions$.pipe(
ofType(ProductsActions.deleteProduct),
concatMap(({ id }) =>
this.productService.deleteProduct(id).pipe(
map(() => ProductsActions.deleteProductSuccess({ id })),
catchError(error => of(ProductsActions.deleteProductFailure({
error: error.message
})))
)
)
)
);
// Success notifications (no dispatch)
showSuccessToast$ = createEffect(() =>
this.actions$.pipe(
ofType(
ProductsActions.createProductSuccess,
ProductsActions.updateProductSuccess,
ProductsActions.deleteProductSuccess
),
tap(action => {
const messages = {
'[Products] Create Product Success': 'Product created successfully',
'[Products] Update Product Success': 'Product updated successfully',
'[Products] Delete Product Success': 'Product deleted successfully'
};
this.toast.success(messages[action.type as keyof typeof messages]);
})
),
{ dispatch: false }
);
// Error notifications
showErrorToast$ = createEffect(() =>
this.actions$.pipe(
ofType(
ProductsActions.loadProductsFailure,
ProductsActions.createProductFailure,
ProductsActions.updateProductFailure,
ProductsActions.deleteProductFailure
),
tap(({ error }) => {
this.toast.error(error);
})
),
{ dispatch: false }
);
}
Using NgRx in Components
Copy
// products/product-list.component.ts
@Component({
selector: 'app-product-list',
standalone: true,
imports: [CommonModule, ProductCardComponent, ProductFiltersComponent],
template: `
@if (vm$ | async; as vm) {
<div class="product-list">
<app-product-filters
[filters]="vm.filters"
(filtersChange)="onFiltersChange($event)"
/>
@if (vm.loading) {
<div class="loading-grid">
@for (i of [1,2,3,4,5,6]; track i) {
<div class="skeleton-card"></div>
}
</div>
} @else if (vm.error) {
<div class="error-state">
<p>{{ vm.error }}</p>
<button (click)="retry()">Retry</button>
</div>
} @else {
<div class="stats-bar">
<span>{{ vm.stats.total }} products</span>
<span>Avg: {{ vm.stats.avgPrice | currency }}</span>
</div>
<div class="products-grid">
@for (product of vm.products; track product.id) {
<app-product-card
[product]="product"
(select)="onSelect($event)"
(addToCart)="onAddToCart($event)"
/>
} @empty {
<div class="empty-state">
<p>No products found</p>
@if (vm.hasFilters) {
<button (click)="clearFilters()">Clear Filters</button>
}
</div>
}
</div>
}
</div>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductListComponent implements OnInit {
private store = inject(Store);
vm$ = this.store.select(selectProductsViewModel);
ngOnInit() {
this.store.dispatch(ProductsActions.loadProducts({}));
}
onFiltersChange(filters: ProductFilters) {
this.store.dispatch(ProductsActions.setFilters({ filters }));
this.store.dispatch(ProductsActions.loadProducts({ filters }));
}
onSelect(product: Product) {
this.store.dispatch(ProductsActions.selectProduct({ id: product.id }));
}
onAddToCart(product: Product) {
this.store.dispatch(CartActions.addItem({
productId: product.id,
quantity: 1
}));
}
clearFilters() {
this.store.dispatch(ProductsActions.clearFilters());
this.store.dispatch(ProductsActions.loadProducts({}));
}
retry() {
this.store.dispatch(ProductsActions.loadProducts({}));
}
}
Using Signal Store (NgRx 17+)
Copy
// products/products.store.ts
import { signalStore, withState, withMethods, withComputed, patchState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { withEntities, setAllEntities, addEntity, updateEntity, removeEntity } from '@ngrx/signals/entities';
interface ProductsState {
loading: boolean;
error: string | null;
filters: ProductFilters;
selectedId: string | null;
}
const initialState: ProductsState = {
loading: false,
error: null,
filters: {},
selectedId: null
};
export const ProductsStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withEntities<Product>(),
withComputed((store) => ({
selectedProduct: computed(() => {
const id = store.selectedId();
return id ? store.entityMap()[id] : null;
}),
filteredProducts: computed(() => {
const products = store.entities();
const filters = store.filters();
return products.filter(p => {
if (filters.category && p.category !== filters.category) return false;
if (filters.minPrice && p.price < filters.minPrice) return false;
if (filters.maxPrice && p.price > filters.maxPrice) return false;
return true;
});
}),
stats: computed(() => {
const products = store.entities();
return {
total: products.length,
avgPrice: products.reduce((sum, p) => sum + p.price, 0) / products.length
};
})
})),
withMethods((store, productService = inject(ProductService)) => ({
loadProducts: rxMethod<ProductFilters>(
pipe(
tap(() => patchState(store, { loading: true, error: null })),
switchMap((filters) =>
productService.getProducts(filters).pipe(
tapResponse({
next: (products) => {
patchState(store, setAllEntities(products));
patchState(store, { loading: false });
},
error: (error: Error) => {
patchState(store, { loading: false, error: error.message });
}
})
)
)
)
),
createProduct: rxMethod<Omit<Product, 'id'>>(
pipe(
switchMap((product) =>
productService.createProduct(product).pipe(
tapResponse({
next: (created) => patchState(store, addEntity(created)),
error: console.error
})
)
)
)
),
updateProduct(id: string, changes: Partial<Product>) {
patchState(store, updateEntity({ id, changes }));
},
deleteProduct(id: string) {
patchState(store, removeEntity(id));
},
selectProduct(id: string | null) {
patchState(store, { selectedId: id });
},
setFilters(filters: ProductFilters) {
patchState(store, { filters });
}
}))
);
// Usage in component
@Component({...})
export class ProductsComponent {
store = inject(ProductsStore);
products = this.store.filteredProducts;
loading = this.store.loading;
stats = this.store.stats;
ngOnInit() {
this.store.loadProducts({});
}
}
NgRx Entity
Entity adapter for managing collections efficiently.Copy
// Advanced entity operations
export const productsReducer = createReducer(
initialState,
// Add multiple
on(ProductsActions.loadProductsSuccess, (state, { products }) =>
adapter.setAll(products, state)
),
// Add one
on(ProductsActions.createProductSuccess, (state, { product }) =>
adapter.addOne(product, state)
),
// Add many
on(ProductsActions.bulkImportSuccess, (state, { products }) =>
adapter.addMany(products, state)
),
// Upsert (add or update)
on(ProductsActions.syncProductSuccess, (state, { product }) =>
adapter.upsertOne(product, state)
),
// Upsert many
on(ProductsActions.syncProductsSuccess, (state, { products }) =>
adapter.upsertMany(products, state)
),
// Update one
on(ProductsActions.updateProductSuccess, (state, { product }) =>
adapter.updateOne({ id: product.id, changes: product }, state)
),
// Update many
on(ProductsActions.bulkUpdateSuccess, (state, { updates }) =>
adapter.updateMany(
updates.map(u => ({ id: u.id, changes: u.changes })),
state
)
),
// Remove one
on(ProductsActions.deleteProductSuccess, (state, { id }) =>
adapter.removeOne(id, state)
),
// Remove many
on(ProductsActions.bulkDeleteSuccess, (state, { ids }) =>
adapter.removeMany(ids, state)
),
// Remove by predicate
on(ProductsActions.removeOutOfStock, (state) =>
adapter.removeMany(
(product) => product.stock === 0,
state
)
),
// Map (transform all entities)
on(ProductsActions.applyDiscount, (state, { percentage }) =>
adapter.map(
(product) => ({
...product,
price: product.price * (1 - percentage / 100)
}),
state
)
)
);
Best Practices
Feature Stores
Organize state by feature with lazy-loaded reducers
Facade Pattern
Create facades to simplify store interactions
Selector Composition
Build complex selectors from simple ones
Effect Operators
Choose the right flattening operator for each use case
Copy
┌─────────────────────────────────────────────────────────────────────────┐
│ Effect Operator Decision Guide │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ switchMap → Use for read operations (search, load) │
│ Cancels previous request when new one comes │
│ Example: Search autocomplete, navigation │
│ │
│ mergeMap → Use for parallel operations that don't conflict │
│ All requests run simultaneously │
│ Example: Adding items to favorites │
│ │
│ concatMap → Use when order matters │
│ Queues requests sequentially │
│ Example: Ordered transactions, uploads │
│ │
│ exhaustMap → Use to prevent duplicate submissions │
│ Ignores new requests while one is pending │
│ Example: Form submissions, payments │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Practice Exercise
1
Build Shopping Cart
Implement a complete cart with NgRx including persistence
2
Add Undo/Redo
Implement undo/redo functionality using NgRx
3
Migrate to Signal Store
Convert a traditional NgRx store to Signal Store
Next: Angular Animations
Create fluid animations and transitions with Angular’s animation system