Skip to main content

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 State Management

NgRx Overview

Estimated Time: 4 hours | Difficulty: Advanced | Prerequisites: RxJS, Services, Signals
NgRx is a reactive state management library for Angular applications, inspired by Redux. Think of it like a centralized ledger for your entire application — every piece of state lives in one place, every change is recorded as an explicit event, and any part of the UI can subscribe to exactly the slice of data it needs. This makes complex data flows predictable and testable, much like how a bank’s transaction log makes every account balance auditable. When do you actually need NgRx? Not every app does. If your state is mostly local to components or shared between a parent and a few children, simple services with signals will do. NgRx earns its complexity when you have state shared across many unrelated components, need undo/redo or time-travel debugging, or when multiple data sources interact in ways that are hard to reason about without a single source of truth.
┌─────────────────────────────────────────────────────────────────────────┐
│                    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 reading selectFilteredProducts, 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: an ids 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

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