Documentation Index
Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt
Use this file to discover all available pages before exploring further.
NgRx Overview
Estimated Time: 4 hours | Difficulty: Advanced | Prerequisites: RxJS, Services, Signals
┌─────────────────────────────────────────────────────────────────────────┐
│ NgRx Architecture Flow │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Component │ ──────► │ Action │ ──────► │ Reducer │ │
│ │ │ dispatch│ │ │ │ │
│ └─────────────┘ └─────────────┘ └──────┬──────┘ │
│ ▲ │ │ │
│ │ │ ▼ │
│ │ │ ┌─────────────┐ │
│ │ │ │ Store │ │
│ │ │ │ (State) │ │
│ │ │ └──────┬──────┘ │
│ │ │ │ │
│ │ ▼ │ │
│ ┌─────────────┐ ┌─────────────┐ │ │
│ │ Selector │ ◄────── │ Store │ ◄─────────────┘ │
│ │ │ select │ (State) │ │
│ └─────────────┘ └─────────────┘ │
│ │ │ │
│ │ │ side effects │
│ │ ▼ │
│ │ ┌─────────────┐ ┌─────────────┐ │
│ └────────────────│ Effects │ ──────► │ Service │ │
│ │ │ API │ │ │
│ └─────────────┘ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Setting Up NgRx
# 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
// 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. Think of them as newspaper headlines — they announce what happened (not what should happen next). Good action names read like past-tense facts: “Products Loaded Successfully,” “User Clicked Delete Button.” This naming discipline is critical because it decouples the event from the reaction, letting you change how you respond to events without rewriting the event itself.Common pitfall: Naming actions as commands (“Load Products”) rather than events (“Load Products Requested”). Command-style names couple the action to a specific handler. Event-style names let multiple reducers and effects respond to the same action independently.
// 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. They take the current state and an action, and return a new state object — never mutating the original. Think of a reducer like a bank teller: they receive a transaction slip (action), look at the current balance (state), and write a new balance (next state). The old balance is never erased — it is simply superseded. This immutability is what makes time-travel debugging and change detection possible.// 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. They serve as the “query layer” for your store — like SQL views over a database. Selectors are memoized by default, meaning they only recompute when their input state actually changes. This is crucial for performance: if you have 10 components readingselectFilteredProducts, the filtering logic runs once per state change, not 10 times.
Practical tip: Build selectors bottom-up. Start with simple “leaf” selectors that read a single property, then compose them into richer “view model” selectors. The
selectProductsViewModel pattern at the bottom of this section is the gold standard — it gives your component exactly one observable to subscribe to, with all the derived data pre-computed.// 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. If reducers are the “pure” part of NgRx (given the same input, always the same output), effects are where the messy real world lives — network requests, localStorage, timers, navigation. Think of effects as backstage crew in a theater: the audience (components) sees the polished result, but effects are behind the curtain doing the actual work of fetching data and dispatching success/failure actions. The most important decision in an effect is which RxJS flattening operator to use. The wrong operator choice is the single most common source of NgRx bugs. See the decision guide at the bottom of this section.// 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);
// switchMap: cancels previous in-flight request when a new one comes in.
// Perfect for "load" operations where only the latest result matters.
// If a user changes filters rapidly, we only care about the final filter state.
loadProducts$ = createEffect(() =>
this.actions$.pipe(
ofType(ProductsActions.loadProducts),
switchMap(({ filters }) =>
this.productService.getProducts(filters).pipe(
map(products => ProductsActions.loadProductsSuccess({ products })),
// catchError INSIDE switchMap so the outer stream stays alive on failure
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
// 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+)
// 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. Instead of storing entities in a plain array (where finding an item by ID is O(n)), NgRx Entity stores them in a normalized shape: anids array for ordering and an entities dictionary for O(1) lookups. Think of it like a database table with a primary key index — you get fast reads, and the adapter handles all the bookkeeping for add, update, upsert, and remove operations.
When to use Entity: Any time you have a collection of items with unique IDs that you need to frequently look up, update, or filter. Product catalogs, user lists, todo items, chat messages — these are all perfect Entity candidates.
// 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
┌─────────────────────────────────────────────────────────────────────────┐
│ 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
Next: Angular Animations
Create fluid animations and transitions with Angular’s animation system