Mastering Advanced NgRx with Angular v20: Deep Dive and Best Practices

Mastering Advanced NgRx with Angular v20: Deep Dive and Best Practices

Welcome to the advanced echelons of NgRx! If you’ve arrived here, it means you’ve successfully navigated the fundamentals and are ready to tackle the more intricate and powerful aspects of reactive state management in your Angular v20 applications. This guide is designed to elevate your NgRx skills from a solid intermediate level to that of a true expert.

We’ll move beyond the basics, diving deep into critical advanced topics such as securing your applications with Auth Guards, mastering complex asynchronous flows with advanced NgRx Effects, optimizing performance, and integrating with real-time data sources and offline capabilities. Every concept will be reinforced with practical, hands-on examples, ensuring that you not only understand the theory but can also immediately apply it to build robust, scalable, and highly performant Angular applications.

Prepare to expand your toolkit and unlock the full potential of NgRx with Angular v20. Let’s begin this exciting journey into advanced reactive state management!


1. Introduction to Advanced NgRx

This section assumes you have a foundational understanding of NgRx (Actions, Reducers, Selectors, Effects) and the NgRx Signal Store, as covered in the beginner’s guide. Here, we’ll quickly recap why these advanced topics are crucial for building enterprise-grade Angular applications.

Why Dive Deeper into NgRx?

While the core NgRx principles provide a robust foundation, real-world applications often demand more sophisticated solutions:

  • Security & User Experience: Protecting routes, showing loading indicators, and gracefully handling errors are vital.
  • Scalability & Maintainability: As applications grow, managing effects efficiently, normalizing state, and reducing boilerplate become paramount.
  • Performance: Optimizing change detection and leveraging memoization is key to a snappy user interface.
  • Real-time & Resilience: Integrating with WebSockets for live updates and building for an “offline-first” experience are modern requirements.
  • Reliability: Rigorous testing, especially of asynchronous logic, ensures application stability.

This guide will empower you with the knowledge and practical experience to confidently implement these advanced patterns.

Setting up Your Advanced Environment

We’ll continue using the Angular v20 project setup. Ensure you have the following NgRx packages installed:

npm install @ngrx/store @ngrx/effects @ngrx/store-devtools @ngrx/entity @ngrx/router-store @ngrx/signals @ngrx/signals-entities @ngrx/operators @ngrx/signals-rxjs-interop rxjs
npm install --save-dev @ngrx/schematics @ngrx/effects-testing

You should have an app.config.ts (for standalone) configured with provideStore, provideEffects, and provideStoreDevtools.

For our advanced examples, we will often expand on the Auth and Product features we started in the beginner guide.


2. Auth Guards with NgRx: Protecting Routes

Protecting parts of your application based on authentication status or user roles is a fundamental requirement. NgRx provides a robust way to manage this through its store, allowing you to create declarative and testable Angular route guards.

Detailed Explanation

Angular provides CanActivate (and CanActivateChild, CanDeactivate, CanMatch) guards that determine if a route can be activated. When using NgRx, these guards typically perform the following steps:

  1. Inject the NgRx Store: To access the application state.
  2. Select Authentication State: Use NgRx selectors to get the isLoggedIn status or user roles from the store.
  3. Pipe with RxJS Operators: Use RxJS operators like take(1) to ensure the observable completes after emitting once, and map or tap to perform conditional logic.
  4. Redirect (if necessary): If the user is not authorized, redirect them to a login page or an unauthorized access page using the Router service.

Important Note for Angular v15+: Functional route guards are the recommended approach. We’ll use these, but the principles apply to class-based guards too.

Code Examples

Let’s enhance our authentication setup from the previous guide to include route protection.

1. Update auth.actions.ts (if not already done):

Ensure you have loginSuccess, loginFailure, logoutSuccess, logoutFailure actions.

2. Update auth.selectors.ts (if not already done):

Ensure you have selectIsLoggedIn and selectCurrentUser selectors.

3. Implement the Auth Guard:

Create src/app/auth/auth.guard.ts:

// src/app/auth/auth.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { selectIsLoggedIn } from './auth.selectors';
import { map, take } from 'rxjs/operators';

export const authGuard: CanActivateFn = (route, state) => {
  const store = inject(Store);
  const router = inject(Router);

  return store.select(selectIsLoggedIn).pipe(
    take(1), // Take only the current value and complete
    map(isLoggedIn => {
      if (isLoggedIn) {
        return true; // User is logged in, allow access
      } else {
        // User is not logged in, redirect to login page
        // You can add a query parameter to return to the original route after login
        console.warn('Auth Guard: User not logged in, redirecting to login.');
        return router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } });
      }
    })
  );
};

export const adminGuard: CanActivateFn = (route, state) => {
  const store = inject(Store);
  const router = inject(Router);

  // Example: Check for a specific role
  return store.select(selectCurrentUser).pipe(
    take(1),
    map(user => {
      // For this mock, let's say only 'admin' user has role 'admin'
      const isAdmin = user && user.username === 'admin';
      if (isAdmin) {
        return true;
      } else {
        console.warn('Admin Guard: User is not an admin, redirecting to unauthorized.');
        return router.createUrlTree(['/unauthorized']);
      }
    })
  );
};

Explanation:

  • CanActivateFn: The type for functional route guards.
  • inject(Store) and inject(Router): Used to get instances of Store and Router in a functional context.
  • store.select(selectIsLoggedIn).pipe(take(1), ...): Selects isLoggedIn and ensures the observable completes immediately.
  • map(isLoggedIn => ...): Transforms the isLoggedIn boolean. If true, returns true (allow access). If false, returns a UrlTree for redirection.
  • router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } }): Constructs a UrlTree that redirects to /login and passes the original intended URL as a query parameter (returnUrl). This is a common pattern to allow users to be redirected back to where they were trying to go after successfully logging in.
  • adminGuard: Demonstrates how you might check for roles. Our mock setup uses a simple username check for demonstration. In a real app, you’d have a role property on the User object.

4. Update app.routes.ts to apply the guard:

// src/app/app.routes.ts
import { Routes } from '@angular/router';
import { AuthComponent } from './auth/auth/auth.component';
import { ProductCatalogComponent } from './products/product-catalog/product-catalog.component';
import { DashboardComponent } from './dashboard/dashboard.component'; // Create this component for a protected route
import { UnauthorizedComponent } from './unauthorized/unauthorized.component'; // Create this component
import { authGuard, adminGuard } from './auth/auth.guard';

export const routes: Routes = [
  { path: '', redirectTo: '/products', pathMatch: 'full' },
  { path: 'login', component: AuthComponent },
  { path: 'products', component: ProductCatalogComponent, canActivate: [authGuard] }, // Protected by authGuard
  { path: 'dashboard', component: DashboardComponent, canActivate: [authGuard, adminGuard] }, // Protected by both guards
  { path: 'unauthorized', component: UnauthorizedComponent },
  { path: '**', redirectTo: '/products' } // Wildcard route for 404
];

5. Create placeholder components (DashboardComponent, UnauthorizedComponent):

// src/app/dashboard/dashboard.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-dashboard',
  standalone: true,
  template: `
    <div style="text-align: center; margin-top: 50px;">
      <h2>Dashboard (Protected Route)</h2>
      <p>Welcome to your private dashboard!</p>
    </div>
  `,
  styles: []
})
export class DashboardComponent { }

// src/app/unauthorized/unauthorized.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-unauthorized',
  standalone: true,
  template: `
    <div style="text-align: center; margin-top: 50px;">
      <h2>Unauthorized Access</h2>
      <p>You do not have permission to view this page.</p>
      <a routerLink="/login">Go to Login</a>
    </div>
  `,
  styles: []
})
export class UnauthorizedComponent { }

6. Update AuthComponent to handle returnUrl (optional but good practice):

// src/app/auth/auth/auth.component.ts (Updated to use returnUrl)
import { Component, inject } from '@angular/core';
import { CommonModule, NgIf } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import * as AuthActions from '../auth.actions';
import { selectAuthError, selectAuthIsLoading, selectCurrentUser, selectIsLoggedIn } from '../auth.selectors';
import { Observable } from 'rxjs';
import { AuthCredentials } from '../auth.model';
import { ActivatedRoute, Router } from '@angular/router'; // Import ActivatedRoute and Router
import { take } from 'rxjs/operators'; // For ActivatedRoute

@Component({
  selector: 'app-auth',
  standalone: true,
  imports: [CommonModule, FormsModule, NgIf],
  template: `
    <div class="auth-container">
      <h2>Authentication</h2>

      <div *ngIf="authIsLoading$ | async">Authenticating...</div>
      <div *ngIf="authError$ | async as error" style="color: red;">Error: {{ error }}</div>

      <ng-container *ngIf="!(isLoggedIn$ | async); else loggedInView">
        <form (ngSubmit)="onLogin()">
          <input type="text" [(ngModel)]="credentials.username" name="username" placeholder="Username (test/admin)">
          <input type="password" [(ngModel)]="credentials.password" name="password" placeholder="Password (password)">
          <button type="submit">Login</button>
        </form>
      </ng-container>

      <ng-template #loggedInView>
        <div *ngIf="currentUser$ | async as user">
          <p>Welcome, {{ user.username }}!</p>
          <p>User ID: {{ user.id }}</p>
          <button (click)="onLogout()">Logout</button>
        </div>
      </ng-template>
    </div>
  `,
  styles: [`
    /* ... (styles remain the same) ... */
  `]
})
export class AuthComponent {
  private store = inject(Store);
  private router = inject(Router); // Inject Router
  private route = inject(ActivatedRoute); // Inject ActivatedRoute

  credentials: AuthCredentials = { username: '', password: '' };
  returnUrl: string | null = null; // To store the return URL

  isLoggedIn$: Observable<boolean> = this.store.select(selectIsLoggedIn);
  currentUser$: Observable<User | null> = this.store.select(selectCurrentUser);
  authIsLoading$: Observable<boolean> = this.store.select(selectAuthIsLoading);
  authError$: Observable<string | null> = this.store.select(selectAuthError);

  constructor() {
    // Get returnUrl from query params when component initializes
    this.route.queryParams.pipe(take(1)).subscribe(params => {
      this.returnUrl = params['returnUrl'] || '/'; // Default to '/'
    });

    // Subscribe to login success to redirect
    this.store.select(selectIsLoggedIn).pipe(
        // only interested in when login state changes to true, and take one emission
        filter(isLoggedIn => isLoggedIn),
        take(1)
    ).subscribe(() => {
        if (this.returnUrl) {
            this.router.navigateByUrl(this.returnUrl);
        } else {
            this.router.navigateByUrl('/');
        }
    });
  }

  onLogin(): void {
    this.store.dispatch(AuthActions.login({ credentials: this.credentials }));
    this.credentials.password = '';
  }

  onLogout(): void {
    this.store.dispatch(AuthActions.logout());
  }
}

Run the application and test:

  1. Navigate to /products or /dashboard. You should be redirected to /login.
  2. Log in with username: test, password: password. You should be redirected back to /products.
  3. Now try navigating to /dashboard. If you logged in as test, you’ll be redirected to /unauthorized.
  4. Logout, then login as username: admin, password: password. Now /dashboard should be accessible.

Exercises/Mini-Challenges

  1. Enhance adminGuard:
    • Modify the User model to include a roles: string[] property.
    • Update AuthService.login to assign ['user'] to test user and ['user', 'admin'] to admin user.
    • Adjust adminGuard to check if user.roles array includes 'admin'.
  2. Add a CanDeactivate guard:
    • Create a new guard unsavedChangesGuard that prompts the user if they try to navigate away from a component (e.g., product edit form) with unsaved changes. The component would have a method like canDeactivate(): boolean which the guard would call.
    • Apply this guard to your product edit form route (if you extract it to its own route).

3. Advanced NgRx Effects: Complex RxJS Operators and Patterns

Effects are where the real power of RxJS shines in NgRx. Mastering advanced RxJS operators and patterns in your effects is crucial for handling complex asynchronous logic, managing concurrency, and ensuring a robust data flow.

Detailed Explanation

The choice of RxJS flattening operator (switchMap, mergeMap, concatMap, exhaustMap) in your effects has significant implications for how concurrent actions are handled.

  • switchMap: Best for “cancel previous” scenarios (e.g., typeahead search). If a new source observable emits, the previous inner observable is unsubscribed.
  • concatMap: Best for “run in order” scenarios (e.g., adding items). Each inner observable completes before the next one starts.
  • mergeMap: Best for “run in parallel” scenarios (e.g., fetching multiple unrelated items). All inner observables are subscribed to and run concurrently.
  • exhaustMap: Best for “ignore new if busy” scenarios (e.g., a “save” button that shouldn’t be clicked multiple times). While the inner observable is active, new emissions from the source are ignored.

Beyond flattening, operators like debounceTime, distinctUntilChanged, withLatestFrom, iif, filter, and delay are incredibly useful.

Code Examples

Let’s refactor and add more advanced effects to our Product Catalog.

1. Update product.actions.ts (add searchProducts, filterProducts actions):

// src/app/products/product.actions.ts
import { createAction, props } from '@ngrx/store';
import { Product } from '../models/product.model';

// Existing actions...
export const loadProducts = createAction('[Products] Load Products');
export const loadProductsSuccess = createAction('[Product API] Load Products Success', props<{ products: Product[] }>());
export const loadProductsFailure = createAction('[Product API] Load Products Failure', props<{ error: string }>());

export const addProduct = createAction('[Products] Add Product', props<{ product: Omit<Product, 'id'> }>());
export const addProductSuccess = createAction('[Product API] Add Product Success', props<{ product: Product }>());
export const addProductFailure = createAction('[Product API] Add Product Failure', props<{ error: string }>());

export const updateProduct = createAction('[Products] Update Product', props<{ id: string; changes: Partial<Product> }>());
export const updateProductSuccess = createAction('[Product API] Update Product Success', props<{ product: Product }>());
export const updateProductFailure = createAction('[Product API] Update Product Failure', props<{ error: string }>());

export const deleteProduct = createAction('[Products] Delete Product', props<{ id: string }>());
export const deleteProductSuccess = createAction('[Product API] Delete Product Success', props<{ id: string }>());
export const deleteProductFailure = createAction('[Product API] Delete Product Failure', props<{ error: string }>());

// New actions for advanced effects
export const searchProducts = createAction('[Products] Search Products', props<{ searchTerm: string }>());
export const filterProducts = createAction('[Products] Filter Products', props<{ category: string | null }>()); // Example filter

2. Update product.reducer.ts (for traditional store, if used) or product.store.ts (for Signal Store):

If you’re still using the traditional store for products, update product.reducer.ts to include searchTerm and category in the ProductState and handle the new actions.

For the Signal Store, we already have searchTerm handled by setSearchTerm method. Let’s add category to the state and methods.

// src/app/products/product.store.ts (Updated for category filter)
import { computed, inject } from '@angular/core';
import { signalStore, withComputed, withMethods, withState, patchState, withLinkedState } from '@ngrx/signals';
import { withEntities, addEntity, removeEntity, updateEntity, setAllEntities, upsertEntity } from '@ngrx/signals/entities';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { pipe, switchMap, tap, of, delay } from 'rxjs';
import { tapResponse } from '@ngrx/operators';
import { Product } from '../models/product.model';
import { ProductService } from './product.service';
import { withLoadingAndError } from '../features/loading-error.feature';

interface ProductsState {
  selectedProductId: string | null;
  searchTerm: string;
  category: string | null; // Add category to state
}

const initialState: ProductsState = {
  selectedProductId: null,
  searchTerm: '',
  category: null,
};

export const ProductStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),
  withEntities<Product>(),
  withLoadingAndError(),
  withComputed(({ entities, selectedProductId, searchTerm, category }) => ({ // Include category here
    allProducts: computed(() => Object.values(entities())),
    filteredProducts: computed(() => {
      let products = Object.values(entities());
      const term = searchTerm().toLowerCase();
      const selectedCategory = category();

      // Apply search term
      if (term) {
        products = products.filter(product =>
          product.name.toLowerCase().includes(term) ||
          product.description.toLowerCase().includes(term)
        );
      }

      // Apply category filter
      if (selectedCategory) {
        // Assuming products have a category property (we'll add this in service later)
        // For now, let's just mock a filter for 'Electronics'
        products = products.filter(product => product.description.includes(selectedCategory));
      }

      return products;
    }),
    selectedProduct: computed(() => {
      const id = selectedProductId();
      return id ? entities()[id] : null;
    }),
    productCount: computed(() => Object.keys(entities()).length),
  })),
  withMethods((store, productService = inject(ProductService)) => ({
    setSearchTerm(term: string) {
      patchState(store, { searchTerm: term });
    },
    setCategory(category: string | null) { // Method to set category
      patchState(store, { category: category });
    },
    selectProduct(id: string | null) {
      patchState(store, { selectedProductId: id });
    },

    loadProducts: rxMethod<void>(
      pipe(
        tap(() => store.setLoading(true)),
        switchMap(() =>
          productService.getProducts().pipe(
            tapResponse({
              next: (products) => patchState(store, setAllEntities(products)),
              error: (err: Error) => store.setError(err.message),
              finalize: () => store.setLoading(false),
            })
          )
        )
      )
    ),

    addProduct: rxMethod<Omit<Product, 'id'>>(
      pipe(
        tap(() => store.setLoading(true)),
        // Using concatMap to ensure products are added in order if dispatched quickly
        concatMap((newProduct) =>
          productService.addProduct(newProduct).pipe(
            tapResponse({
              next: (product) => patchState(store, addEntity(product)),
              error: (err: Error) => store.setError(err.message),
              finalize: () => store.setLoading(false),
            })
          )
        )
      )
    ),

    updateProduct: rxMethod<{ id: string; changes: Partial<Product> }>(
      pipe(
        tap(() => store.setLoading(true)),
        // Using exhaustMap here to prevent multiple update requests while one is in progress
        exhaustMap(({ id, changes }) =>
          productService.updateProduct(id, changes).pipe(
            tapResponse({
              next: (product) => patchState(store, updateEntity({ id: product.id, changes: product })),
              error: (err: Error) => store.setError(err.message),
              finalize: () => store.setLoading(false),
            })
          )
        )
      )
    ),

    deleteProduct: rxMethod<string>(
      pipe(
        tap(() => store.setLoading(true)),
        // Using mergeMap for delete, as deletions can potentially happen in parallel
        mergeMap((id) =>
          productService.deleteProduct(id).pipe(
            tapResponse({
              next: (deletedId) => patchState(store, removeEntity(deletedId)),
              error: (err: Error) => store.setError(err.message),
              finalize: () => store.setLoading(false),
            })
          )
        )
      )
    ),
  })),
);

3. Create/Update Product Effects for Traditional Store (if you use it for products):

If using the traditional store for products, you’d create src/app/products/product.effects.ts:

// src/app/products/product.effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, map, concatMap, switchMap, tap, debounceTime, distinctUntilChanged, withLatestFrom, filter } from 'rxjs/operators';
import { of } from 'rxjs';
import { Store } from '@ngrx/store'; // For withLatestFrom
import * as ProductActions from './product.actions';
import { ProductService } from './product.service';
import { AppState } from '../app.config'; // Assuming you have a root AppState interface
import { selectProductSearchTerm, selectProductCategory } from './product.selectors'; // Need to create these selectors

@Injectable()
export class ProductEffects {
  constructor(
    private actions$: Actions,
    private productService: ProductService,
    private store: Store<AppState> // Inject Store for withLatestFrom
  ) {}

  loadProducts$ = createEffect(() =>
    this.actions$.pipe(
      ofType(ProductActions.loadProducts),
      switchMap(() =>
        this.productService.getProducts().pipe(
          map(products => ProductActions.loadProductsSuccess({ products })),
          catchError(error => of(ProductActions.loadProductsFailure({ error: error.message })))
        )
      )
    )
  );

  addProduct$ = createEffect(() =>
    this.actions$.pipe(
      ofType(ProductActions.addProduct),
      concatMap(action =>
        this.productService.addProduct(action.product).pipe(
          map(product => ProductActions.addProductSuccess({ product })),
          catchError(error => of(ProductActions.addProductFailure({ error: error.message })))
        )
      )
  ));

  updateProduct$ = createEffect(() =>
    this.actions$.pipe(
      ofType(ProductActions.updateProduct),
      // Use exhaustMap here to prevent multiple update requests while one is in progress
      exhaustMap(action =>
        this.productService.updateProduct(action.id, action.changes).pipe(
          map(product => ProductActions.updateProductSuccess({ product })),
          catchError(error => of(ProductActions.updateProductFailure({ error: error.message })))
        )
      )
  ));

  deleteProduct$ = createEffect(() =>
    this.actions$.pipe(
      ofType(ProductActions.deleteProduct),
      // Use mergeMap here as deletions can happen in parallel
      mergeMap(action =>
        this.productService.deleteProduct(action.id).pipe(
          map(id => ProductActions.deleteProductSuccess({ id })),
          catchError(error => of(ProductActions.deleteProductFailure({ error: error.message })))
        )
      )
  ));

  // Advanced Effect: Search Products with debounceTime and distinctUntilChanged
  searchProducts$ = createEffect(() =>
    this.actions$.pipe(
      ofType(ProductActions.searchProducts),
      debounceTime(300), // Wait for 300ms after the last key stroke
      distinctUntilChanged((prev, curr) => prev.searchTerm === curr.searchTerm), // Only proceed if search term changed
      withLatestFrom(this.store.select(selectProductCategory)), // Get the current category filter
      switchMap(([action, category]) => {
        // Here you would typically call a backend API with both search term and category
        console.log(`Performing search for "${action.searchTerm}" in category "${category || 'all'}"`);
        // For demonstration, we will dispatch a loadProducts action with filtered results
        // In a real app, your service would handle the actual filtering on the backend
        return this.productService.getProducts().pipe( // Re-fetch all products
          map(products => {
            const filtered = products.filter(p =>
              p.name.toLowerCase().includes(action.searchTerm.toLowerCase()) &&
              (!category || p.description.includes(category)) // Simple mock category check
            );
            return ProductActions.loadProductsSuccess({ products: filtered }); // Update state with filtered list
          }),
          catchError(error => of(ProductActions.loadProductsFailure({ error: error.message })))
        );
      })
    )
  );

  // Advanced Effect: Filter Products
  filterProducts$ = createEffect(() =>
    this.actions$.pipe(
      ofType(ProductActions.filterProducts),
      // Use switchMap to cancel previous filter if a new one is applied quickly
      withLatestFrom(this.store.select(selectProductSearchTerm)), // Get the current search term
      switchMap(([action, searchTerm]) => {
        console.log(`Applying filter for category "${action.category}" with search term "${searchTerm}"`);
        // Similar to search, your service would typically handle this on the backend
        return this.productService.getProducts().pipe(
          map(products => {
            const filtered = products.filter(p =>
              (!searchTerm || p.name.toLowerCase().includes(searchTerm.toLowerCase())) &&
              (!action.category || p.description.includes(action.category)) // Simple mock category check
            );
            return ProductActions.loadProductsSuccess({ products: filtered });
          }),
          catchError(error => of(ProductActions.loadProductsFailure({ error: error.message })))
        );
      })
    )
  );
}

4. Update app.config.ts to register the new effects:

// src/app/app.config.ts
import { provideEffects } from '@ngrx/effects';
import { ProductEffects } from './products/product.effects'; // Import ProductEffects

export const appConfig: ApplicationConfig = {
  providers: [
    // ... other providers
    provideEffects([
        AuthEffects,
        ProductEffects, // Register ProductEffects
    ]),
    // ...
  ]
};

5. Update ProductCatalogComponent to dispatch new actions (for traditional store only):

If using the traditional store, you’d modify ProductCatalogComponent to dispatch searchProducts and filterProducts actions.

// src/app/products/product-catalog/product-catalog.component.ts (for traditional store)
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule, NgFor, NgIf } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import * as ProductActions from '../product.actions';
import { Product } from '../../models/product.model';
import { selectFilteredProducts, selectProductError, selectProductIsLoading } from '../product.selectors'; // Assuming these exist

@Component({
  selector: 'app-product-catalog',
  standalone: true,
  imports: [CommonModule, FormsModule, NgFor, NgIf],
  template: `
    <div class="product-catalog-container">
      <h2>Product Catalog (NgRx Traditional Store)</h2>

      <div *ngIf="isLoading$ | async">Loading products...</div>
      <div *ngIf="error$ | async as error" style="color: red;">Error: {{ error }}</div>

      <div class="controls">
        <input type="text" [(ngModel)]="searchTerm" (input)="onSearchChange()" placeholder="Search products">
        <select [(ngModel)]="selectedCategory" (change)="onCategoryChange()">
          <option [ngValue]="null">All Categories</option>
          <option value="Laptop">Laptops</option>
          <option value="Mouse">Mice</option>
          <option value="Keyboard">Keyboards</option>
          <option value="Electronics">Electronics</option>
        </select>
        <button (click)="onRefreshProducts()">Refresh Products</button>
      </div>

      <!-- Add/Edit/List logic would go here -->
    </div>
  `,
  styles: [`/* ... styles ... */`]
})
export class ProductCatalogComponent implements OnInit {
  private store = inject(Store);

  searchTerm: string = '';
  selectedCategory: string | null = null;

  filteredProducts$ = this.store.select(selectFilteredProducts);
  isLoading$ = this.store.select(selectProductIsLoading);
  error$ = this.store.select(selectProductError);

  ngOnInit(): void {
    this.store.dispatch(ProductActions.loadProducts());
  }

  onSearchChange(): void {
    this.store.dispatch(ProductActions.searchProducts({ searchTerm: this.searchTerm }));
  }

  onCategoryChange(): void {
    this.store.dispatch(ProductActions.filterProducts({ category: this.selectedCategory }));
  }

  onRefreshProducts(): void {
    this.store.dispatch(ProductActions.loadProducts());
  }

  // ... other methods for add/edit/delete
  trackById(index: number, product: Product): string { return product.id; }
}

Run the application and test:

  1. Observe how quickly new products are added (using concatMap).
  2. Try updating a product multiple times rapidly. Notice how exhaustMap ensures only one update request is in flight.
  3. Type quickly into the search box. debounceTime and distinctUntilChanged should prevent excessive API calls.
  4. Switch categories. Notice how switchMap ensures only the latest filter request is considered.

Exercises/Mini-Challenges

  1. Implement a “save with confirmation” effect:
    • Create an action saveProductConfirmed that is dispatched after a user confirms an action (e.g., a modal asks “Are you sure?”).
    • Create an effect confirmAndSaveProduct$ that listens for a saveProduct action, then opens a confirmation dialog (simulate with a confirm() call or a service). If confirmed, dispatch saveProductConfirmed.
    • Modify your existing updateProduct$ effect to only respond to saveProductConfirmed. This uses a common pattern of “action splitting” to manage complex user interactions.
  2. Chaining Effects:
    • Create a scenario where a loginSuccess action (from your AuthEffects) automatically triggers a loadUserPreferences action (for a new UserPreferencesEffects). This demonstrates how effects can communicate by dispatching actions that other effects listen to.

4. NgRx Data: Reducing Boilerplate for Entity Management

@ngrx/data is a powerful library that sits on top of @ngrx/store and @ngrx/effects. It provides a highly opinionated, convention-based approach to managing entity collections, significantly reducing the boilerplate code typically associated with CRUD operations. While ngrx/signals/entities is excellent for Signal Stores, ngrx/data is invaluable for traditional NgRx Store setups.

Detailed Explanation

@ngrx/data automates the creation of:

  • Actions: For CRUD operations (e.g., loadAll, addOne, updateOne, delete, queryMany).
  • Reducers: To manage the entity collection in the state.
  • Selectors: To query entities from the state.
  • Effects: To handle API interactions for CRUD operations.

You define your entity models, register them with EntityDataModule, and ngrx/data generates everything else based on conventions. You typically only need to write custom code for non-standard API endpoints or complex business logic.

Code Examples

Let’s convert our Product Catalog from the traditional NgRx Store (or if you only used Signal Store, imagine converting it from a basic traditional store) to use ngrx/data.

1. Install @ngrx/data:

npm install @ngrx/data

2. Update src/app/models/product.model.ts:

No changes needed to the model itself.

3. Delete product.actions.ts, product.reducer.ts, product.effects.ts:

These files will be largely replaced by ngrx/data. You might keep product.service.ts if you want a custom HttpClient facade, but ngrx/data also provides its own.

4. Register EntityDataModule in app.config.ts:

// src/app/app.config.ts
import { ApplicationConfig, isDevMode } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http'; // Required for ngrx/data
import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { provideEntityData, withHttpResourceUrls } from '@ngrx/data'; // Import ngrx/data providers

import { routes } from './app.routes';
import { authReducer } from './auth/auth.reducer';
import { AuthEffects } from './auth/auth.effects';

// Assuming you define these in an array
const entityMetadata = {
  Product: {
    // You can define custom selectors, actions, or entityDispacher options here
    // e.g., filterFn: filterProductsByName,
    // You can also define an entityCollectionReducerMethodsFactory to customize reducer behavior
  }
};

const pluralNames = { Product: 'Products' }; // Often needed for REST APIs (e.g., /api/products)

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(), // Essential for ngrx/data to make HTTP calls
    provideStore({
      auth: authReducer,
      // 'products' slice will be managed by ngrx/data
    }),
    provideEffects([AuthEffects]), // Only custom effects needed
    provideEntityData(entityMetadata, { pluralNames }), // Configure ngrx/data
    // Optional: If your API endpoints don't match the convention (e.g., /api/heroes instead of /heroes)
    withHttpResourceUrls({
        entityHttpResourceUrls: {
            Product: {
                entityResourceUrl: '/api/products/', // Adjust this to your backend API
                collectionResourceUrl: '/api/products/',
            }
        }
    }),
    provideStoreDevtools({ maxAge: 25, logOnly: !isDevMode() }),
  ]
};

5. Update product.service.ts (Mock Product API) to fit ngrx/data conventions:

ngrx/data expects endpoints like /api/products for collection operations and /api/products/{id} for single entity operations. Our mock ProductService is already pretty close. We won’t strictly need this ProductService as a direct dependency anymore, but it’s good to keep it as a backend mock.

6. Create a ProductEntityService (using EntityCollectionServiceBase):

This is the main interaction point for components.

// src/app/products/product-entity.service.ts
import { Injectable } from '@angular/core';
import { EntityCollectionServiceBase, EntityCollectionServiceElementsFactory } from '@ngrx/data';
import { Product } from '../models/product.model';

@Injectable({ providedIn: 'root' })
export class ProductEntityService extends EntityCollectionServiceBase<Product> {
  constructor(serviceElementsFactory: EntityCollectionServiceElementsFactory) {
    super('Product', serviceElementsFactory); // 'Product' must match the entity name in entityMetadata
  }
}

7. Update ProductCatalogComponent to use ProductEntityService:

// src/app/products/product-catalog/product-catalog.component.ts (Using NgRx Data)
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule, NgFor, NgIf } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Product } from '../../models/product.model';
import { ProductEntityService } from '../product-entity.service'; // Import the new service
import { Observable } from 'rxjs';
import { filter, debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { FormControl } from '@angular/forms';

@Component({
  selector: 'app-product-catalog',
  standalone: true,
  imports: [CommonModule, FormsModule, NgFor, NgIf],
  template: `
    <div class="product-catalog-container">
      <h2>Product Catalog (NgRx Data)</h2>

      <div *ngIf="loading$ | async">Loading products...</div>
      <div *ngIf="error$ | async as error" style="color: red;">Error: {{ error }}</div>

      <div class="controls">
        <input type="text" [formControl]="searchControl" placeholder="Search products">
        <button (click)="productService.getAll()">Refresh Products</button>
      </div>

      <h3>Add New Product</h3>
      <div class="add-product-form">
        <input type="text" [(ngModel)]="newProductName" placeholder="Product Name">
        <input type="number" [(ngModel)]="newProductPrice" placeholder="Price">
        <textarea [(ngModel)]="newProductDescription" placeholder="Description"></textarea>
        <button (click)="addNewProduct()">Add Product</button>
      </div>

      <ul class="product-list">
        <li *ngFor="let product of (filteredProducts$ | async); trackBy: trackById">
          <div>
            <strong>{{ product.name }}</strong> - \${{ product.price }}
            <p>{{ product.description }}</p>
          </div>
          <div class="actions">
            <button (click)="editProduct(product)">Edit</button>
            <button (click)="deleteProduct(product)">Delete</button>
          </div>
        </li>
      </ul>

      <div *ngIf="selectedProduct" class="edit-product-form">
        <h3>Edit Product: {{ selectedProduct.name }}</h3>
        <input type="text" [(ngModel)]="editedProduct.name">
        <input type="number" [(ngModel)]="editedProduct.price">
        <textarea [(ngModel)]="editedProduct.description"></textarea>
        <button (click)="saveEditedProduct()">Save Changes</button>
        <button (click)="cancelEdit()">Cancel</button>
      </div>
    </div>
  `,
  styles: [`/* ... styles ... */`]
})
export class ProductCatalogComponent implements OnInit {
  // Inject the NgRx Data service for products
  readonly productService = inject(ProductEntityService);

  filteredProducts$: Observable<Product[]> = this.productService.entities$;
  loading$: Observable<boolean> = this.productService.loading$;
  error$: Observable<any> = this.productService.errors$.pipe(map(error => error?.payload?.error?.message || 'Unknown error'));


  newProductName: string = '';
  newProductPrice: number = 0;
  newProductDescription: string = '';

  selectedProduct: Product | null = null;
  editedProduct: Product = { id: '', name: '', price: 0, description: '' };

  searchControl = new FormControl(''); // For reactive search input

  constructor() {
    // Manually filter products based on search term for now
    this.searchControl.valueChanges.pipe(
      debounceTime(300),
      distinctUntilChanged()
    ).subscribe(term => {
      // In a real app, ngrx/data can query your backend with this term
      // For now, we filter in memory or trigger a new API call using getAll(queryParams)
      // If your API supports search, you'd do:
      // this.productService.getAll({ name: term || '' }); // or a custom query
    });

    this.productService.selectedEntity$.subscribe(product => {
      this.selectedProduct = product;
      if (product) {
        this.editedProduct = { ...product };
      }
    });
  }

  ngOnInit(): void {
    this.productService.getAll(); // Load all products on init
  }

  addNewProduct(): void {
    if (this.newProductName && this.newProductPrice > 0 && this.newProductDescription) {
      const newProduct: Omit<Product, 'id'> = {
        name: this.newProductName,
        price: this.newProductPrice,
        description: this.newProductDescription,
      };
      this.productService.add(newProduct); // ngrx/data add method
      this.clearNewProductForm();
    }
  }

  editProduct(product: Product): void {
    this.productService.setSelected(product); // ngrx/data setSelected method
  }

  saveEditedProduct(): void {
    if (this.editedProduct.id && this.editedProduct.name && this.editedProduct.price > 0 && this.editedProduct.description) {
      this.productService.update(this.editedProduct); // ngrx/data update method
      this.productService.setSelected(null); // Clear selection
    }
  }

  cancelEdit(): void {
    this.productService.setSelected(null); // Clear selection
  }

  deleteProduct(product: Product): void {
    if (confirm('Are you sure you want to delete this product?')) {
      this.productService.delete(product); // ngrx/data delete method
    }
  }

  private clearNewProductForm(): void {
    this.newProductName = '';
    this.newProductPrice = 0;
    this.newProductDescription = '';
  }

  trackById(index: number, product: Product): string {
    return product.id;
  }
}

Run the application and test:

Observe how much code for actions, reducers, and effects you eliminated! ngrx/data automatically handles the CRUD boilerplate.

  1. Add, edit, delete products.
  2. Observe the Redux DevTools: You’ll see ngrx/data actions being dispatched (e.g., @ngrx/data/query-all/success, @ngrx/data/add-one/success).

Exercises/Mini-Challenges

  1. Customize ngrx/data for specific API endpoints:
    • Imagine your “add product” API endpoint is /api/products/create instead of just /api/products.
    • Explore withHttpResourceUrls in app.config.ts to map the add operation for Product to this custom URL.
  2. Add a custom selector for ngrx/data:
    • Create a custom selector that returns only products above a certain price.
    • Explore EntitySelectorsFactory to create custom selectors for your ProductEntityService.

5. State Normalization: Structuring Complex Data

When dealing with interconnected data (entities), especially those with relationships (e.g., products with categories, users with posts), storing them directly in nested arrays can lead to duplication, inconsistencies, and difficult updates. State normalization is a technique to solve this.

Detailed Explanation

Normalization means structuring your state:

  1. Each type of entity gets its own “table” (an object where keys are entity IDs).
  2. References are used instead of embedding data.
  3. Arrays are used to store IDs for ordering or selection.

This approach is inspired by database normalization. The @ngrx/entity library (used under the hood by ngrx/signals/entities and ngrx/data) is designed precisely for this, providing helper functions to manage normalized collections.

Code Examples

Let’s normalize our product state, adding a Category entity and linking products to categories. We’ll use traditional NgRx for this example to show the direct use of @ngrx/entity.

1. Install @ngrx/entity:

npm install @ngrx/entity

2. Define new models (Category) and update Product:

// src/app/models/category.model.ts
export interface Category {
  id: string;
  name: string;
}

// src/app/models/product.model.ts (Updated)
export interface Product {
  id: string;
  name: string;
  price: number;
  description: string;
  categoryId: string; // Link to Category
}

3. Create category.actions.ts:

// src/app/categories/category.actions.ts
import { createAction, props } from '@ngrx/store';
import { Category } from '../models/category.model';

export const loadCategories = createAction('[Category] Load Categories');
export const loadCategoriesSuccess = createAction('[Category API] Load Categories Success', props<{ categories: Category[] }>());
export const loadCategoriesFailure = createAction('[Category API] Load Categories Failure', props<{ error: string }>());

4. Create category.reducer.ts using createEntityAdapter:

// src/app/categories/category.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity';
import * as CategoryActions from './category.actions';
import { Category } from '../models/category.model';

// EntityState provides 'ids' and 'entities' properties
export interface CategoryState extends EntityState<Category> {
  isLoading: boolean;
  error: string | null;
}

// createEntityAdapter provides methods to manage entity collections
export const categoryAdapter: EntityAdapter<Category> = createEntityAdapter<Category>();

export const initialCategoryState: CategoryState = categoryAdapter.getInitialState({
  isLoading: false,
  error: null,
});

export const categoryReducer = createReducer(
  initialCategoryState,
  on(CategoryActions.loadCategories, (state) => ({ ...state, isLoading: true, error: null })),
  on(CategoryActions.loadCategoriesSuccess, (state, { categories }) =>
    categoryAdapter.setAll(categories, { ...state, isLoading: false, error: null }) // Use setAll to update entities
  ),
  on(CategoryActions.loadCategoriesFailure, (state, { error }) => ({
    ...state,
    isLoading: false,
    error,
  })),
);

5. Create category.selectors.ts:

// src/app/categories/category.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { CategoryState, categoryAdapter } from './category.reducer';

export const selectCategoryState = createFeatureSelector<CategoryState>('category');

// Selectors generated by the adapter
const {
  selectIds,
  selectEntities,
  selectAll,
  selectTotal,
} = categoryAdapter.getSelectors();

export const selectAllCategories = createSelector(selectCategoryState, selectAll);
export const selectCategoryEntities = createSelector(selectCategoryState, selectEntities);
export const selectCategoryIsLoading = createSelector(selectCategoryState, (state) => state.isLoading);
export const selectCategoryError = createSelector(selectCategoryState, (state) => state.error);

export const selectCategoryById = (id: string) => createSelector(
    selectCategoryEntities,
    (entities) => entities[id]
);

6. Create category.service.ts (Mock API):

// src/app/categories/category.service.ts
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { delay } from 'rxjs/operators';
import { Category } from '../models/category.model';

@Injectable({ providedIn: 'root' })
export class CategoryService {
  private categories: Category[] = [
    { id: 'cat1', name: 'Electronics' },
    { id: 'cat2', name: 'Books' },
    { id: 'cat3', name: 'Home Appliances' },
  ];

  getCategories(): Observable<Category[]> {
    console.log('Category API: Fetching categories...');
    return of([...this.categories]).pipe(delay(600));
  }
}

7. Create category.effects.ts:

// src/app/categories/category.effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, map, switchMap } from 'rxjs/operators';
import { of } from 'rxjs';
import * as CategoryActions from './category.actions';
import { CategoryService } from './category.service';

@Injectable()
export class CategoryEffects {
  constructor(private actions$: Actions, private categoryService: CategoryService) {}

  loadCategories$ = createEffect(() =>
    this.actions$.pipe(
      ofType(CategoryActions.loadCategories),
      switchMap(() =>
        this.categoryService.getCategories().pipe(
          map(categories => CategoryActions.loadCategoriesSuccess({ categories })),
          catchError(error => of(CategoryActions.loadCategoriesFailure({ error: error.message })))
        )
      )
    )
  );
}

8. Update app.config.ts to register categoryReducer and CategoryEffects:

// src/app/app.config.ts
import { categoryReducer } from './categories/category.reducer';
import { CategoryEffects } from './categories/category.effects';

export const appConfig: ApplicationConfig = {
  providers: [
    // ...
    provideStore({
      auth: authReducer,
      // If using ngrx/data for products, keep it separate from manual product reducer
      // If you are migrating product to traditional store with entity, you'd add:
      // product: productReducer,
      category: categoryReducer, // Register category reducer
    }),
    provideEffects([
        AuthEffects,
        CategoryEffects, // Register category effects
        // ProductEffects, // If using product traditional effects
    ]),
    // ...
  ]
};

9. Update product.service.ts to include categoryId in mock data:

// src/app/products/product.service.ts (Updated with categoryId)
import { Injectable } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { delay } from 'rxjs/operators';
import { Product } from '../models/product.model';

@Injectable({ providedIn: 'root' })
export class ProductService {
  private products: Product[] = [
    { id: 'p1', name: 'Laptop', price: 1200, description: 'High-performance laptop', categoryId: 'cat1' },
    { id: 'p2', name: 'Mouse', price: 25, description: 'Wireless optical mouse', categoryId: 'cat1' },
    { id: 'p3', name: 'Book: Learn NgRx', price: 50, description: 'A great book about state management', categoryId: 'cat2' },
  ];
  private nextId = 4;

  // ... (rest of methods remain the same, just adjust mock product creation/update to include categoryId)
}

10. Create product.reducer.ts and product.selectors.ts using createEntityAdapter for Product (if not using ngrx/data for products):

// src/app/products/product.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity';
import * as ProductActions from './product.actions';
import { Product } from '../models/product.model';

export interface ProductState extends EntityState<Product> {
  isLoading: boolean;
  error: string | null;
  selectedProductId: string | null;
  // Add category filter here if needed, or manage it separately
}

export const productAdapter: EntityAdapter<Product> = createEntityAdapter<Product>();

export const initialProductState: ProductState = productAdapter.getInitialState({
  isLoading: false,
  error: null,
  selectedProductId: null,
});

export const productReducer = createReducer(
  initialProductState,
  on(ProductActions.loadProducts, (state) => ({ ...state, isLoading: true, error: null })),
  on(ProductActions.loadProductsSuccess, (state, { products }) =>
    productAdapter.setAll(products, { ...state, isLoading: false, error: null })
  ),
  on(ProductActions.loadProductsFailure, (state, { error }) => ({ ...state, isLoading: false, error })),

  on(ProductActions.addProductSuccess, (state, { product }) =>
    productAdapter.addOne(product, { ...state, isLoading: false, error: null })
  ),
  on(ProductActions.addProductFailure, (state, { error }) => ({ ...state, isLoading: false, error })),

  on(ProductActions.updateProductSuccess, (state, { product }) =>
    productAdapter.updateOne({ id: product.id, changes: product }, { ...state, isLoading: false, error: null })
  ),
  on(ProductActions.updateProductFailure, (state, { error }) => ({ ...state, isLoading: false, error })),

  on(ProductActions.deleteProductSuccess, (state, { id }) =>
    productAdapter.removeOne(id, { ...state, isLoading: false, error: null })
  ),
  on(ProductActions.deleteProductFailure, (state, { error }) => ({ ...state, isLoading: false, error })),
);


// src/app/products/product.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { ProductState, productAdapter } from './product.reducer';
import { selectCategoryEntities } from '../categories/category.selectors'; // Import category selectors

export const selectProductState = createFeatureSelector<ProductState>('product');

const {
  selectIds: selectProductIds,
  selectEntities: selectProductEntities,
  selectAll: selectAllProducts,
  selectTotal: selectTotalProducts,
} = productAdapter.getSelectors();

export const getSelectedProductId = createSelector(selectProductState, (state) => state.selectedProductId);

export const selectProductIsLoading = createSelector(selectProductState, (state) => state.isLoading);
export const selectProductError = createSelector(selectProductState, (state) => state.error);

// Combine products with categories
export const selectProductsWithCategories = createSelector(
    selectAllProducts,
    selectCategoryEntities,
    (products, categoryEntities) => {
        return products.map(product => ({
            ...product,
            category: categoryEntities[product.categoryId] // Add category object to product
        }));
    }
);

export const selectProductById = (id: string) => createSelector(
    selectProductEntities,
    (entities) => entities[id]
);

11. Update ProductCatalogComponent to display categories and use normalized data:

// src/app/products/product-catalog/product-catalog.component.ts (Using normalized state with NgRx Entity)
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule, NgFor, NgIf } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import * as ProductActions from '../product.actions';
import * as CategoryActions from '../../categories/category.actions';
import { Product } from '../../models/product.model';
import { Category } from '../../models/category.model';
import { selectProductError, selectProductIsLoading, selectProductsWithCategories, getSelectedProductId, selectProductById } from '../product.selectors';
import { selectAllCategories } from '../../categories/category.selectors';
import { Observable } from 'rxjs';
import { withLatestFrom, map } from 'rxjs/operators';

interface ProductWithCategory extends Product {
    category: Category | undefined;
}

@Component({
  selector: 'app-product-catalog',
  standalone: true,
  imports: [CommonModule, FormsModule, NgFor, NgIf],
  template: `
    <div class="product-catalog-container">
      <h2>Product Catalog (Normalized State)</h2>

      <div *ngIf="productIsLoading$ | async">Loading products...</div>
      <div *ngIf="categoryIsLoading$ | async">Loading categories...</div>
      <div *ngIf="productError$ | async as error" style="color: red;">Product Error: {{ error }}</div>
      <div *ngIf="categoryError$ | async as error" style="color: red;">Category Error: {{ error }}</div>

      <div class="controls">
        <input type="text" [(ngModel)]="searchTerm" (input)="onSearchChange()" placeholder="Search products">
        <select [(ngModel)]="selectedCategoryId" (change)="onCategoryFilterChange()">
          <option [ngValue]="null">All Categories</option>
          <option *ngFor="let category of (allCategories$ | async)" [ngValue]="category.id">{{ category.name }}</option>
        </select>
        <button (click)="onRefreshProducts()">Refresh Products</button>
      </div>

      <h3>Add New Product</h3>
      <div class="add-product-form">
        <input type="text" [(ngModel)]="newProductName" placeholder="Product Name">
        <input type="number" [(ngModel)]="newProductPrice" placeholder="Price">
        <textarea [(ngModel)]="newProductDescription" placeholder="Description"></textarea>
        <select [(ngModel)]="newProductCategoryId">
          <option [ngValue]="null" disabled>Select Category</option>
          <option *ngFor="let category of (allCategories$ | async)" [ngValue]="category.id">{{ category.name }}</option>
        </select>
        <button (click)="addNewProduct()">Add Product</button>
      </div>

      <ul class="product-list">
        <li *ngFor="let product of (filteredProductsWithCategory$ | async); trackBy: trackById">
          <div>
            <strong>{{ product.name }}</strong> - \${{ product.price }}
            <p>{{ product.description }}</p>
            <small>Category: {{ product.category?.name || 'N/A' }}</small>
          </div>
          <div class="actions">
            <button (click)="editProduct(product)">Edit</button>
            <button (click)="deleteProduct(product.id)">Delete</button>
          </div>
        </li>
      </ul>

      <div *ngIf="selectedProduct$ | async as selectedProduct" class="edit-product-form">
        <h3>Edit Product: {{ selectedProduct.name }}</h3>
        <input type="text" [(ngModel)]="editedProduct.name">
        <input type="number" [(ngModel)]="editedProduct.price">
        <textarea [(ngModel)]="editedProduct.description"></textarea>
        <select [(ngModel)]="editedProduct.categoryId">
          <option [ngValue]="null" disabled>Select Category</option>
          <option *ngFor="let category of (allCategories$ | async)" [ngValue]="category.id">{{ category.name }}</option>
        </select>
        <button (click)="saveEditedProduct()">Save Changes</button>
        <button (click)="cancelEdit()">Cancel</button>
      </div>
    </div>
  `,
  styles: [`/* ... styles ... */`]
})
export class ProductCatalogComponent implements OnInit {
  private store = inject(Store);

  searchTerm: string = '';
  selectedCategoryId: string | null = null;

  newProductName: string = '';
  newProductPrice: number = 0;
  newProductDescription: string = '';
  newProductCategoryId: string | null = null;

  editedProduct: Product = { id: '', name: '', price: 0, description: '', categoryId: '' };

  // Observables for product state
  productIsLoading$: Observable<boolean> = this.store.select(selectProductIsLoading);
  productError$: Observable<string | null> = this.store.select(selectProductError);
  
  // Observables for category state
  allCategories$: Observable<Category[]> = this.store.select(selectAllCategories);
  categoryIsLoading$: Observable<boolean> = this.store.select(selectCategoryIsLoading);
  categoryError$: Observable<string | null> = this.store.select(selectCategoryError);

  // Selector for all products combined with their category names
  allProductsWithCategory$: Observable<ProductWithCategory[]> = this.store.select(selectProductsWithCategories);

  // Filtered products (combining search and category filter)
  filteredProductsWithCategory$: Observable<ProductWithCategory[]> = this.allProductsWithCategory$.pipe(
    map(products => {
      let filtered = products;
      if (this.searchTerm) {
        filtered = filtered.filter(p => p.name.toLowerCase().includes(this.searchTerm.toLowerCase()) || p.description.toLowerCase().includes(this.searchTerm.toLowerCase()));
      }
      if (this.selectedCategoryId) {
        filtered = filtered.filter(p => p.categoryId === this.selectedCategoryId);
      }
      return filtered;
    })
  );

  selectedProduct$: Observable<Product | undefined> = this.store.select(getSelectedProductId).pipe(
    withLatestFrom(this.store.select(selectProductEntities)),
    map(([selectedId, entities]) => selectedId ? entities[selectedId] : undefined)
  );

  constructor() {
    this.selectedProduct$.subscribe(product => {
      if (product) {
        this.editedProduct = { ...product };
      }
    });
  }

  ngOnInit(): void {
    this.store.dispatch(ProductActions.loadProducts());
    this.store.dispatch(CategoryActions.loadCategories());
  }

  onSearchChange(): void {
    // This will trigger the filteredProductsWithCategory$ map operator
  }

  onCategoryFilterChange(): void {
    // This will trigger the filteredProductsWithCategory$ map operator
  }

  onRefreshProducts(): void {
    this.store.dispatch(ProductActions.loadProducts());
    this.store.dispatch(CategoryActions.loadCategories());
  }

  addNewProduct(): void {
    if (this.newProductName && this.newProductPrice > 0 && this.newProductDescription && this.newProductCategoryId) {
      const newProduct: Omit<Product, 'id'> = {
        name: this.newProductName,
        price: this.newProductPrice,
        description: this.newProductDescription,
        categoryId: this.newProductCategoryId,
      };
      this.store.dispatch(ProductActions.addProduct({ product: newProduct }));
      this.clearNewProductForm();
    }
  }

  editProduct(product: ProductWithCategory): void {
      this.store.dispatch(ProductActions.selectProduct({ id: product.id })); // You'd need this action in product.actions/reducer
  }

  saveEditedProduct(): void {
    if (this.editedProduct.id && this.editedProduct.name && this.editedProduct.price > 0 && this.editedProduct.description && this.editedProduct.categoryId) {
      this.store.dispatch(ProductActions.updateProduct({ id: this.editedProduct.id, changes: this.editedProduct }));
      this.store.dispatch(ProductActions.clearSelectedProduct()); // You'd need this action
    }
  }

  cancelEdit(): void {
      this.store.dispatch(ProductActions.clearSelectedProduct());
  }

  deleteProduct(id: string): void {
    if (confirm('Are you sure you want to delete this product?')) {
      this.store.dispatch(ProductActions.deleteProduct({ id }));
    }
  }

  private clearNewProductForm(): void {
    this.newProductName = '';
    this.newProductPrice = 0;
    this.newProductDescription = '';
    this.newProductCategoryId = null;
  }

  trackById(index: number, product: ProductWithCategory): string {
    return product.id;
  }
}

Run the application and test:

  1. Observe the Redux DevTools. You’ll see product and category state slices each managed with ids and entities.
  2. Add products, select categories for new products, filter by category.
  3. Note how selectProductsWithCategories combines data from both normalized state slices into a denormalized view for the UI.

Exercises/Mini-Challenges

  1. Add a selectProduct and clearSelectedProduct action/reducer logic:
    • Implement these in product.actions.ts and product.reducer.ts to properly manage selectedProductId in the ProductState.
    • Modify ProductCatalogComponent to use these actions.
  2. Add a tags entity and link to products:
    • Create a Tag model and a Tag NgRx module (actions, reducer, selectors, effects).
    • Add tagIds: string[] to the Product model.
    • Update relevant services and components to display and manage tags on products. This will further solidify your understanding of many-to-many relationships.

6. Testing with TestScheduler: Mastering Effects Testing

Testing asynchronous NgRx Effects can be challenging. The TestScheduler from RxJS provides a powerful way to test your RxJS observables, including effects, in a synchronous and predictable manner.

Detailed Explanation

TestScheduler allows you to define a “marble diagram” string that represents the timing of events in an observable stream. This diagram uses characters to denote events:

  • -: Time pass (1 unit of virtual time).
  • a, b, c, …: Values emitted by the observable.
  • |: Completion of the observable.
  • #: Error in the observable.
  • (): Grouping multiple synchronous emissions.

By using TestScheduler, you can precisely control the timing of actions dispatched to your effect and the responses from mocked services, making asynchronous tests deterministic and easier to debug.

Code Examples

Let’s write a TestScheduler test for our loadProducts$ effect.

1. Create a mock for ProductService:

// src/app/products/product.service.mock.ts
import { of, throwError } from 'rxjs';
import { Product } from '../models/product.model';

export const mockProducts: Product[] = [
  { id: 'p1', name: 'Mock Laptop', price: 1000, description: 'Mock description', categoryId: 'cat1' },
  { id: 'p2', name: 'Mock Mouse', price: 20, description: 'Another mock', categoryId: 'cat1' },
];

export const mockProductService = {
  getProducts: jasmine.createSpy('getProducts').and.returnValue(of(mockProducts)),
  addProduct: jasmine.createSpy('addProduct').and.callFake((product: Omit<Product, 'id'>) =>
    of({ ...product, id: 'p_new' })
  ),
  updateProduct: jasmine.createSpy('updateProduct').and.callFake((id: string, changes: Partial<Product>) =>
    of({ id, ...changes } as Product)
  ),
  deleteProduct: jasmine.createSpy('deleteProduct').and.returnValue(of('p_delete')),
};

2. Write the effect test using TestScheduler and provideMockActions:

Create src/app/products/product.effects.spec.ts:

// src/app/products/product.effects.spec.ts
import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { provideMockStore } from '@ngrx/store/testing';
import { Observable, of, throwError } from 'rxjs';
import { TestScheduler } from 'rxjs/testing'; // Import TestScheduler

import { ProductEffects } from './product.effects';
import { ProductService } from './product.service';
import * as ProductActions from './product.actions';
import { mockProductService, mockProducts } from './product.service.mock';

describe('ProductEffects', () => {
  let actions$: Observable<any>;
  let effects: ProductEffects;
  let productService: jasmine.SpyObj<ProductService>;
  let testScheduler: TestScheduler;

  beforeEach(() => {
    testScheduler = new TestScheduler((actual, expected) => {
      expect(actual).toEqual(expected);
    });

    TestBed.configureTestingModule({
      providers: [
        ProductEffects,
        provideMockActions(() => actions$),
        provideMockStore(), // Mock the store if effects use it (e.g., withLatestFrom)
        { provide: ProductService, useValue: mockProductService },
      ],
    });

    effects = TestBed.inject(ProductEffects);
    productService = TestBed.inject(ProductService) as jasmine.SpyObj<ProductService>;
  });

  it('should load products and return loadProductsSuccess', () => {
    testScheduler.run(({ hot, cold, expectObservable }) => {
      actions$ = hot('-a', { a: ProductActions.loadProducts() }); // Dispatch loadProducts
      const response = cold('--b|', { b: mockProducts }); // Service returns products after 2 frames
      productService.getProducts.and.returnValue(response);

      const expected = '---c'; // Expect success action after 3 frames (1 for action, 2 for service)
      expectObservable(effects.loadProducts$).toBe(expected, {
        c: ProductActions.loadProductsSuccess({ products: mockProducts }),
      });
    });
  });

  it('should load products and return loadProductsFailure', () => {
    testScheduler.run(({ hot, cold, expectObservable }) => {
      actions$ = hot('-a', { a: ProductActions.loadProducts() });
      const error = new Error('Load failed!');
      const response = cold('--#|', {}, error); // Service throws error after 2 frames
      productService.getProducts.and.returnValue(response);

      const expected = '---c'; // Expect failure action after 3 frames
      expectObservable(effects.loadProducts$).toBe(expected, {
        c: ProductActions.loadProductsFailure({ error: error.message }),
      });
    });
  });

  it('should add a product and return addProductSuccess', () => {
    testScheduler.run(({ hot, cold, expectObservable }) => {
      const newProductData = { name: 'New Item', price: 10, description: 'Desc', categoryId: 'cat1' };
      const addedProduct = { ...newProductData, id: 'p_new' };

      actions$ = hot('-a', { a: ProductActions.addProduct({ product: newProductData }) });
      const response = cold('--b|', { b: addedProduct });
      productService.addProduct.and.returnValue(response);

      const expected = '---c';
      expectObservable(effects.addProduct$).toBe(expected, {
        c: ProductActions.addProductSuccess({ product: addedProduct }),
      });
    });
  });

  // Test with a more complex effect (e.g., searchProducts from previous section)
  it('should debounce and search for products', () => {
    testScheduler.run(({ hot, cold, expectObservable }) => {
      // Mock store selector for selectProductCategory if used by effect
      const mockCategorySelector = provideMockStore({
          selectors: [{ selector: selectProductCategory, value: null }] // Or 'Electronics'
      });
      // Reconfigure TestBed to include mock store for this test
      TestBed.resetTestingModule(); // Reset previous config
      TestBed.configureTestingModule({
          providers: [
              ProductEffects,
              provideMockActions(() => actions$),
              mockCategorySelector,
              { provide: ProductService, useValue: mockProductService },
          ],
      });
      effects = TestBed.inject(ProductEffects);
      productService = TestBed.inject(ProductService) as jasmine.SpyObj<ProductService>;


      // User types 'l', 'a', 'p', then pauses, then 't', 'o', 'p'
      actions$ = hot('a 299ms b 49ms c 300ms d', { // a: search('l'), b: search('la'), c: search('lap'), d: search('laptop')
        a: ProductActions.searchProducts({ searchTerm: 'l' }),
        b: ProductActions.searchProducts({ searchTerm: 'la' }),
        c: ProductActions.searchProducts({ searchTerm: 'lap' }),
        d: ProductActions.searchProducts({ searchTerm: 'laptop' }),
      });
      const getProductsResponse = cold('--e|', { e: mockProducts }); // Service always returns mockProducts
      productService.getProducts.and.returnValue(getProductsResponse);

      const expectedFilteredProducts = mockProducts.filter(p => p.name.toLowerCase().includes('lap'));
      const expectedFilteredProductsFinal = mockProducts.filter(p => p.name.toLowerCase().includes('laptop'));

      // 300ms debounce + 2 frames for service response
      // 'c' action at frame 300, debounced until 600, then service for 2 frames = 602
      // 'd' action at frame 600 (300+300), debounced until 900, then service for 2 frames = 902
      expectObservable(effects.searchProducts$).toBe('------- 300ms -- (f|)', { // Visual representation of the marbles
         // The last 'c' action (searchTerm 'lap') will pass debounce at ~600ms, then service at 602ms
         // The last 'd' action (searchTerm 'laptop') will pass debounce at ~900ms, then service at 902ms
         // Due to switchMap, the 'lap' search would be cancelled by 'laptop' search if they overlapped.
         // Given the timings, the 'lap' request would finish, then 'laptop' request starts.
         // Let's adjust for precise debounce and switchMap behavior.

         // For 'lap': action at 300ms, debounce for 300ms => starts at 600ms. Service takes 2ms => completes 602ms.
         // For 'laptop': action at 600ms, debounce for 300ms => starts at 900ms. Service takes 2ms => completes 902ms.
         // switchMap would cancel prior if a new one arrives BEFORE prior's inner observable emits.
         // Here, 'lap' completes BEFORE 'laptop' inner observable is subscribed.
         // So we would expect two emissions.
         // But distinctUntilChanged prevents 'b' from emitting anything, as 'a' is 'l', and 'b' is 'la'

         // Let's refine the scenario:
         // -a(l) ---b(la) ---c(lap) ----d(laptop)
         // Assuming all hot emissions are within 1ms or so.
         // a(0), b(300), c(350), d(650)
         // Search action stream:
         // 0: 'l' -> debounce starts
         // 300: 'la' -> debounce restarts
         // 350: 'lap' -> debounce restarts
         // 650: 'laptop' -> debounce restarts
         // Nothing will come out before the last one finishes its debounce, as `distinctUntilChanged` is after `debounceTime`.
         // This means only 'laptop' would likely trigger, after 300ms from the 'laptop' action.
         // So, action at 650ms, debounce for 300ms -> effect starts at 950ms. Service takes 2ms. Completes at 952ms.

         // Re-simulating with TestScheduler's timing:
         // 'a' (time 0) -> debounced (no output)
         // ' ' (time 1) -> debounced (no output)
         // ...
         // 'a' (time 0)
         // ' ' (time 1)
         // 299ms
         // 'b' (time 300)
         // 49ms
         // 'c' (time 350+49=399) Wait, this timing is relative. Let's make it simpler
         // actions$ = hot('a-b-c-d', {a,b,c,d}) means 4 frames.
         // hot('-a-b-c-d 300ms e', {a,b,c,d,e})
         // Let's use simpler relative times:
         // a ----b----c----d (300ms between each)
         // actions$ = hot('a 299ms b 299ms c 299ms d', { ... })
         // action 'd' is at time 900 (0 + 300 + 300 + 300).
         // It then debounces for 300ms. So starts at 1200ms.
         // Service takes 2ms.
         // So result at 1202ms.

        // Simpler example for debounce and distinctUntilChanged
        // User types 'f', then 'fo', then 'foo', then 'f' (should not emit 'f' again due to distinct)
        // Let's assume selectProductCategory is returning null
        actions$ = hot('-a-b-c-d-e', {
          a: ProductActions.searchProducts({ searchTerm: 'f' }),
          b: ProductActions.searchProducts({ searchTerm: 'fo' }),
          c: ProductActions.searchProducts({ searchTerm: 'foo' }),
          d: ProductActions.searchProducts({ searchTerm: 'foo' }), // Same as 'c', should be ignored
          e: ProductActions.searchProducts({ searchTerm: 'f' }), // Different than 'foo', should trigger
        });
        const getProductsResponse = cold('--x|', { x: mockProducts }); // Always mock successful API call
        productService.getProducts.and.returnValue(getProductsResponse);

        // Expect the following events after debounce (300ms) and service response (2ms)
        // a ('f'): Trigger search at 301ms (1ms + 300ms debounce)
        // b ('fo'): Trigger search at 303ms (3ms + 300ms debounce)
        // c ('foo'): Trigger search at 305ms (5ms + 300ms debounce)
        // d ('foo'): Distinct until changed will block this if c was 'foo'
        // e ('f'): Trigger search at 309ms (9ms + 300ms debounce)

        // The actual timing will be relative to the last emission of the source.
        // Let's trace it for debounce(300) and distinctUntilChanged
        // A: 0ms -> search('f')
        // B: 1ms -> search('fo')
        // C: 2ms -> search('foo')
        // D: 3ms -> search('foo') (blocked by distinctUntilChanged after debounce)
        // E: 4ms -> search('f')

        // Assuming 1ms per marble diagram char:
        // Input: -a-b-c-d-e|
        // Output from debounceTime(3): ---x---y---z---A|  (x=a, y=b, z=c, A=e)
        // distinctUntilChanged(on searchTerm): ---x---y---z---A| (d is ignored)
        // withLatestFrom (no change in store assumed): ---x---y---z---A|
        // switchMap + cold('--x|') for service (2 frames)
        // Expected: -----a'---b'---c'---A'|

        expectObservable(effects.searchProducts$).toBe('--- 298ms (cde)', { // Timing is tricky here, let's simplify for example
             c: ProductActions.loadProductsSuccess({ products: mockProducts.filter(p => p.name.toLowerCase().includes('foo')) }),
             d: ProductActions.loadProductsSuccess({ products: mockProducts.filter(p => p.name.toLowerCase().includes('foo')) }), // If distinct not blocking
             e: ProductActions.loadProductsSuccess({ products: mockProducts.filter(p => p.name.toLowerCase().includes('f')) }),
        });
      }, {
          initialState: {
              product: {
                  ...initialProductState,
                  searchTerm: '', // Initial search term
              }
          }
      });
    });
  });
});

Explanation:

  • testScheduler = new TestScheduler(...): Creates the scheduler instance.
  • testScheduler.run(({ hot, cold, expectObservable }) => { ... });: This is where your test logic lives.
    • hot('-a', { a: ProductActions.loadProducts() }): Creates a “hot” observable for actions. a emits loadProducts immediately (after 1 “frame” of -).
    • cold('--b|', { b: mockProducts }): Creates a “cold” observable for the service response. b emits mockProducts after 2 frames, then completes.
    • productService.getProducts.and.returnValue(response): Mocks the service call to return the cold observable.
    • expectObservable(effects.loadProducts$).toBe(expected, { ... });: Asserts that the output of effects.loadProducts$ matches the expected marble diagram and the expected values (c).

The TestScheduler takes practice, but it’s an indispensable tool for reliable asynchronous testing.

Exercises/Mini-Challenges

  1. Test updateProduct$ effect:
    • Write a TestScheduler test for updateProduct$ using exhaustMap.
    • Simulate dispatching updateProduct twice very quickly. Verify that only the first one triggers a service call and the second one is ignored.
  2. Test an effect that uses withLatestFrom:
    • If you have an effect that uses withLatestFrom(this.store.select(someSelector)), ensure you configure provideMockStore with initial state or selectors for the mock store.
    • Write a test that verifies the effect correctly combines the action payload with the latest state from the store.

7. Performance Optimization: OnPush, Memoized Selectors, and Immutability

Performance is paramount for a good user experience. NgRx, when used correctly, can significantly boost your Angular application’s performance, primarily through optimized change detection and efficient state access.

Detailed Explanation

  1. OnPush Change Detection Strategy:
    • By default, Angular components use Default change detection, meaning they are checked for changes frequently (on every event, timer, HTTP response).
    • OnPush strategy tells Angular to only check a component and its children when:
      • An input property changes (checked by reference).
      • An event originated from the component or one of its children.
      • An observable in the template using the async pipe emits a new value.
      • ChangeDetectorRef.detectChanges() or ChangeDetectorRef.markForCheck() is explicitly called.
    • Using OnPush widely in your application, especially with NgRx, forces you to treat state immutably, as changes to objects/arrays within the same reference will not trigger updates.
  2. Memoized Selectors:
    • NgRx createSelector creates memoized selectors. This means a selector’s output is cached. It will only re-execute its projection function if one of its input selectors returns a new reference.
    • This prevents unnecessary re-calculations of derived state and is a cornerstone of NgRx performance.
  3. Immutability:
    • As discussed earlier, always return new objects/arrays when updating state. This is fundamental for OnPush to work effectively and for selectors to re-memoize correctly.
  4. Minimizing async pipes:
    • While async pipe is great, too many in a single template can still trigger multiple subscriptions. Consider using a single async pipe at the top level and then destructuring the state, or using *ngIf="state$ | async as state" to bind it once.
  5. Smallest Possible State Updates:
    • When dispatching actions or using patchState, only include the minimal changes needed. This keeps the new state objects smaller and faster to create.

Code Examples

Let’s apply OnPush and demonstrate selector usage.

1. Apply OnPush to ProductCatalogComponent (and other components that consume store data):

// src/app/products/product-catalog/product-catalog.component.ts (Updated for OnPush)
import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core'; // Import ChangeDetectionStrategy
import { CommonModule, NgFor, NgIf } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import * as ProductActions from '../product.actions';
import * as CategoryActions from '../../categories/category.actions';
import { Product } from '../../models/product.model';
import { Category } from '../../models/category.model';
import { selectProductError, selectProductIsLoading, selectProductsWithCategories, getSelectedProductId, selectProductById } from '../product.selectors';
import { selectAllCategories } from '../../categories/category.selectors';
import { Observable } from 'rxjs';
import { withLatestFrom, map } from 'rxjs/operators';

interface ProductWithCategory extends Product {
    category: Category | undefined;
}

@Component({
  selector: 'app-product-catalog',
  standalone: true,
  imports: [CommonModule, FormsModule, NgFor, NgIf],
  template: `
    <div class="product-catalog-container">
      <h2>Product Catalog (Performance Optimized)</h2>

      <div *ngIf="productIsLoading$ | async">Loading products...</div>
      <div *ngIf="categoryIsLoading$ | async">Loading categories...</div>
      <div *ngIf="productError$ | async as error" style="color: red;">Product Error: {{ error }}</div>
      <div *ngIf="categoryError$ | async as error" style="color: red;">Category Error: {{ error }}</div>

      <div class="controls">
        <input type="text" [(ngModel)]="searchTerm" (input)="onSearchChange()" placeholder="Search products">
        <select [(ngModel)]="selectedCategoryId" (change)="onCategoryFilterChange()">
          <option [ngValue]="null">All Categories</option>
          <option *ngFor="let category of (allCategories$ | async)" [ngValue]="category.id">{{ category.name }}</option>
        </select>
        <button (click)="onRefreshProducts()">Refresh Products</button>
      </div>

      <h3>Add New Product</h3>
      <div class="add-product-form">
        <input type="text" [(ngModel)]="newProductName" placeholder="Product Name">
        <input type="number" [(ngModel)]="newProductPrice" placeholder="Price">
        <textarea [(ngModel)]="newProductDescription" placeholder="Description"></textarea>
        <select [(ngModel)]="newProductCategoryId">
          <option [ngValue]="null" disabled>Select Category</option>
          <option *ngFor="let category of (allCategories$ | async)" [ngValue]="category.id">{{ category.name }}</option>
        </select>
        <button (click)="addNewProduct()">Add Product</button>
      </div>

      <ul class="product-list">
        <li *ngFor="let product of (filteredProductsWithCategory$ | async); trackBy: trackById">
          <div>
            <strong>{{ product.name }}</strong> - \${{ product.price }}
            <p>{{ product.description }}</p>
            <small>Category: {{ product.category?.name || 'N/A' }}</small>
          </div>
          <div class="actions">
            <button (click)="editProduct(product)">Edit</button>
            <button (click)="deleteProduct(product.id)">Delete</button>
          </div>
        </li>
      </ul>

      <div *ngIf="selectedProduct$ | async as selectedProduct" class="edit-product-form">
        <h3>Edit Product: {{ selectedProduct.name }}</h3>
        <input type="text" [(ngModel)]="editedProduct.name">
        <input type="number" [(ngModel)]="editedProduct.price">
        <textarea [(ngModel)]="editedProduct.description"></textarea>
        <select [(ngModel)]="editedProduct.categoryId">
          <option [ngValue]="null" disabled>Select Category</option>
          <option *ngFor="let category of (allCategories$ | async)" [ngValue]="category.id">{{ category.name }}</option>
        </select>
        <button (click)="saveEditedProduct()">Save Changes</button>
        <button (click)="cancelEdit()">Cancel</button>
      </div>
    </div>
  `,
  styles: [`/* ... styles ... */`],
  changeDetection: ChangeDetectionStrategy.OnPush, // Apply OnPush strategy
})
export class ProductCatalogComponent implements OnInit {
  // ... (rest of the component logic remains the same)
}

2. Ensure Selectors are Composed and Memoized (already covered but re-emphasized):

Our selectProductsWithCategories is a great example of a composed and memoized selector. It only re-calculates when selectAllProducts OR selectCategoryEntities emit a new reference.

3. Use async pipe effectively:

Notice how we use *ngIf="observable$ | async as data" pattern, or bind observables directly in *ngFor to minimize manual subscriptions and leverage OnPush.

Run the application:

While OnPush changes might not be visually obvious in a small app, using Angular DevTools (Component Explorer > Profiler) can show you how often components are checked. With OnPush, your components will be checked far less frequently, leading to improved performance, especially in larger applications.

Exercises/Mini-Challenges

  1. Refactor with component-store (or NgRx Signal Store for local state):
    • For the search term and selected category, instead of using ngModel and component properties, create a small ComponentStore (or a local SignalStore) within ProductCatalogComponent to manage searchTerm and selectedCategoryId.
    • This keeps the component’s internal state isolated and leverages NgRx patterns even for local data.
  2. Profile Change Detection:
    • Install the Angular DevTools extension for Chrome/Firefox.
    • Open your application and the DevTools. Go to the “Profiler” tab and record a session.
    • Interact with your product catalog (add, delete, search, filter).
    • Analyze the change detection cycles. Observe how the OnPush components are only checked when relevant inputs change or events occur.
    • Compare this to removing ChangeDetectionStrategy.OnPush from ProductCatalogComponent.

8. Integrating with WebSockets: Real-time Data

Many modern applications require real-time updates (e.g., chat applications, live dashboards, stock tickers). NgRx provides an excellent framework for integrating with WebSockets to manage real-time data flow.

Detailed Explanation

The typical pattern for integrating WebSockets with NgRx involves:

  1. WebSocket Service: A service responsible for establishing and maintaining the WebSocket connection, sending messages, and exposing an RxJS Observable for incoming messages.
  2. NgRx Effect: An effect that listens for an action to initiate the WebSocket connection (connectWebSocket).
    • It then uses RxJS operators to:
      • Listen for incoming WebSocket messages (using mergeMap or switchMap to flatten the WebSocket observable).
      • Map incoming messages to NgRx actions (e.g., productUpdatedRealtime, chatMessageReceived).
      • Dispatch these actions to the store.
      • Handle connection/disconnection, errors, and reconnections.
  3. Reducers: Update the state based on the real-time actions.
  4. Selectors: Select and display the real-time data in components.

Code Examples

Let’s simulate real-time product updates using a mock WebSocket service.

1. Create websocket.service.ts (Mock WebSocket):

// src/app/shared/websocket/websocket.service.ts
import { Injectable } from '@angular/core';
import { Subject, Observable, timer, interval, ReplaySubject } from 'rxjs';
import { takeUntil, tap, switchMap, delay, shareReplay } from 'rxjs/operators';
import { Product } from '../../models/product.model';

// This will simulate a WebSocket connection
@Injectable({ providedIn: 'root' })
export class WebSocketService {
  private disconnectSubject = new Subject<void>();
  private messages$$ = new ReplaySubject<any>(1); // Use ReplaySubject to buffer messages

  public messages$: Observable<any> = this.messages$$.asObservable();

  constructor() {
    console.log('WebSocketService initialized.');
    this.connect(); // Auto-connect for this example
  }

  // Simulate connecting to a WebSocket
  connect() {
    console.log('WebSocket: Attempting to connect...');
    // Simulate connection after a delay
    timer(1000).pipe(
      tap(() => console.log('WebSocket: Connected. Simulating real-time updates.')),
      switchMap(() => this.createMockWebSocketStream()),
      takeUntil(this.disconnectSubject),
    ).subscribe(
      (msg) => {
        console.log('WebSocket: Received message:', msg);
        this.messages$$.next(msg);
      },
      (error) => console.error('WebSocket Error:', error),
      () => console.log('WebSocket: Disconnected.'),
    );
  }

  disconnect() {
    console.log('WebSocket: Disconnecting...');
    this.disconnectSubject.next();
    this.messages$$.complete();
  }

  // Simulate sending a message to the server (not strictly used for this example's effect)
  send(message: any): void {
    console.log('WebSocket: Sending message:', message);
    // In a real WebSocket, you'd use ws.send(JSON.stringify(message))
  }

  // Simulate a stream of real-time product updates
  private createMockWebSocketStream(): Observable<any> {
    const products: Product[] = [
      { id: 'p1', name: 'Laptop', price: 1200, description: 'High-performance laptop', categoryId: 'cat1' },
      { id: 'p2', name: 'Mouse', price: 25, description: 'Wireless optical mouse', categoryId: 'cat1' },
      { id: 'p3', name: 'Keyboard', price: 75, description: 'Mechanical keyboard', categoryId: 'cat1' },
    ];

    let currentProductIndex = 0;
    return interval(5000).pipe( // Emit a new product update every 5 seconds
      map(() => {
        const productToUpdate = { ...products[currentProductIndex % products.length] };
        // Simulate a price change
        productToUpdate.price = Math.round(productToUpdate.price * (1 + (Math.random() - 0.5) * 0.1)); // +/- 5%
        productToUpdate.name = productToUpdate.name + ' (Live Update)'; // Indicate update
        currentProductIndex++;
        return { type: '[Product Realtime] Product Price Updated', payload: productToUpdate };
      }),
      shareReplay(1) // Share the stream and replay last message
    );
  }
}

2. Create a new action for real-time updates:

// src/app/products/product.actions.ts (Updated)
// ... existing actions

export const productPriceUpdatedRealtime = createAction(
  '[Product Realtime] Product Price Updated',
  props<{ product: Product }>()
);

export const connectProductWebSocket = createAction('[App] Connect Product WebSocket');
export const disconnectProductWebSocket = createAction('[App] Disconnect Product WebSocket');

3. Create an effect to handle WebSocket messages:

// src/app/products/product-realtime.effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, map, switchMap, takeUntil, tap } from 'rxjs/operators';
import { EMPTY, of } from 'rxjs';
import * as ProductActions from './product.actions';
import { WebSocketService } from '../shared/websocket/websocket.service';
import { Store } from '@ngrx/store';
import { Product } from '../models/product.model';
import { updateEntity } from '@ngrx/signals/entities'; // If using Signal Store for products
import { patchState } from '@ngrx/signals'; // If using Signal Store

@Injectable()
export class ProductRealtimeEffects {
  constructor(
    private actions$: Actions,
    private webSocketService: WebSocketService,
    private store: Store // Only if traditional store, not needed for SignalStore if patchState is within its own method
  ) {}

  // Effect to connect to the WebSocket and listen for messages
  connectWebSocket$ = createEffect(() =>
    this.actions$.pipe(
      ofType(ProductActions.connectProductWebSocket),
      // Use switchMap to cancel previous connection if a new connect action is dispatched
      switchMap(() => this.webSocketService.messages$.pipe(
        map((message) => {
          // Assuming message.type matches NgRx action type for simplicity
          // In a real app, you might parse the message and dispatch different actions
          if (message.type === '[Product Realtime] Product Price Updated' && message.payload) {
            return ProductActions.productPriceUpdatedRealtime({ product: message.payload });
          }
          return { type: 'NO_ACTION' }; // Placeholder for unknown message types
        }),
        catchError((error) => {
          console.error('WebSocket message stream error:', error);
          // Dispatch a generic error action or specific to WebSocket
          return EMPTY; // Stop processing this stream on error
        }),
        takeUntil(this.actions$.pipe(ofType(ProductActions.disconnectProductWebSocket))),
      )),
      // Filter out 'NO_ACTION' if no specific action was mapped
      filter((action) => action.type !== 'NO_ACTION')
    )
  );

  // Effect to disconnect from the WebSocket
  disconnectWebSocket$ = createEffect(() =>
    this.actions$.pipe(
      ofType(ProductActions.disconnectProductWebSocket),
      tap(() => this.webSocketService.disconnect())
    ),
    { dispatch: false } // This effect does not dispatch actions
  );

  // You would also have a reducer or Signal Store method to update the product in state
  // This is how it would look for a traditional store:
  // updateProductPriceInStore$ = createEffect(() =>
  //     this.actions$.pipe(
  //         ofType(ProductActions.productPriceUpdatedRealtime),
  //         concatMap(action => of(ProductActions.updateProductSuccess({ product: action.product }))) // Dispatching a normal update action
  //     )
  // );

  // If using NgRx Signal Store, you'd integrate this in your ProductStore methods
  // We'll show this next.
}

4. Update app.config.ts to register ProductRealtimeEffects:

// src/app/app.config.ts
import { ProductRealtimeEffects } from './products/product-realtime.effects';

export const appConfig: ApplicationConfig = {
  providers: [
    // ...
    provideEffects([
        AuthEffects,
        CategoryEffects,
        // ProductEffects, // If using product traditional effects
        ProductRealtimeEffects, // Register real-time effects
    ]),
    // ...
  ]
};

5. Update product.reducer.ts (for traditional store) or product.store.ts (for Signal Store) to handle productPriceUpdatedRealtime action:

For traditional store (product.reducer.ts):

// src/app/products/product.reducer.ts (Updated for real-time)
// ... existing reducer logic

on(ProductActions.productPriceUpdatedRealtime, (state, { product }) =>
  productAdapter.updateOne({ id: product.id, changes: product }, state) // Update the product directly
),

For NgRx Signal Store (product.store.ts):

We need to add a method to the ProductStore that leverages rxMethod to subscribe to the WebSocket and update the state.

// src/app/products/product.store.ts (Updated for real-time)
import { computed, inject } from '@angular/core';
import { signalStore, withComputed, withMethods, withState, patchState, withLinkedState } from '@ngrx/signals';
import { withEntities, addEntity, removeEntity, updateEntity, setAllEntities, upsertEntity } from '@ngrx/signals/entities';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { pipe, switchMap, tap, of, delay, takeUntil, filter } from 'rxjs';
import { tapResponse } from '@ngrx/operators';
import { Product } from '../models/product.model';
import { ProductService } from './product.service';
import { withLoadingAndError } from '../features/loading-error.feature';
import { WebSocketService } from '../shared/websocket/websocket.service'; // Import WebSocketService

interface ProductsState {
  selectedProductId: string | null;
  searchTerm: string;
  category: string | null;
  // No need for a separate isLoading/error for websocket here, as we update product directly
}

const initialState: ProductsState = {
  selectedProductId: null,
  searchTerm: '',
  category: null,
};

export const ProductStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),
  withEntities<Product>(),
  withLoadingAndError(),
  withComputed(({ entities, selectedProductId, searchTerm, category }) => ({
    allProducts: computed(() => Object.values(entities())),
    filteredProducts: computed(() => {
      let products = Object.values(entities());
      const term = searchTerm().toLowerCase();
      const selectedCategory = category();

      if (term) {
        products = products.filter(product =>
          product.name.toLowerCase().includes(term) ||
          product.description.toLowerCase().includes(term)
        );
      }
      if (selectedCategory) {
        products = products.filter(product => product.description.includes(selectedCategory));
      }
      return products;
    }),
    selectedProduct: computed(() => {
      const id = selectedProductId();
      return id ? entities()[id] : null;
    }),
    productCount: computed(() => Object.keys(entities()).length),
  })),
  withMethods((store, productService = inject(ProductService), webSocketService = inject(WebSocketService)) => ({ // Inject WebSocketService
    setSearchTerm(term: string) {
      patchState(store, { searchTerm: term });
    },
    setCategory(category: string | null) {
      patchState(store, { category: category });
    },
    selectProduct(id: string | null) {
      patchState(store, { selectedProductId: id });
    },

    loadProducts: rxMethod<void>(
      pipe(
        tap(() => store.setLoading(true)),
        switchMap(() =>
          productService.getProducts().pipe(
            tapResponse({
              next: (products) => patchState(store, setAllEntities(products)),
              error: (err: Error) => store.setError(err.message),
              finalize: () => store.setLoading(false),
            })
          )
        )
      )
    ),

    addProduct: rxMethod<Omit<Product, 'id'>>(
      pipe(
        tap(() => store.setLoading(true)),
        concatMap((newProduct) =>
          productService.addProduct(newProduct).pipe(
            tapResponse({
              next: (product) => patchState(store, addEntity(product)),
              error: (err: Error) => store.setError(err.message),
              finalize: () => store.setLoading(false),
            })
          )
        )
      )
    ),

    updateProduct: rxMethod<{ id: string; changes: Partial<Product> }>(
      pipe(
        tap(() => store.setLoading(true)),
        exhaustMap(({ id, changes }) =>
          productService.updateProduct(id, changes).pipe(
            tapResponse({
              next: (product) => patchState(store, updateEntity({ id: product.id, changes: product })),
              error: (err: Error) => store.setError(err.message),
              finalize: () => store.setLoading(false),
            })
          )
        )
      )
    ),

    deleteProduct: rxMethod<string>(
      pipe(
        tap(() => store.setLoading(true)),
        mergeMap((id) =>
          productService.deleteProduct(id).pipe(
            tapResponse({
              next: (deletedId) => patchState(store, removeEntity(deletedId)),
              error: (err: Error) => store.setError(err.message),
              finalize: () => store.setLoading(false),
            })
          )
        )
      )
    ),

    // New: Handle real-time product updates from WebSocket
    startRealtimeUpdates: rxMethod<void>(
        pipe(
            // Use switchMap to restart the WebSocket subscription if this method is called again
            switchMap(() => webSocketService.messages$.pipe(
                filter(message => message.type === '[Product Realtime] Product Price Updated' && message.payload),
                map(message => message.payload as Product),
                tap((product) => {
                    // Update the entity in the store directly
                    patchState(store, updateEntity({ id: product.id, changes: product }));
                }),
                // Optionally handle disconnect, e.g., by listening to another action
                takeUntil(store.selectProduct(null)) // Example: Disconnect if a product is deselected
            ))
        )
    ),

    // Manual connect/disconnect methods for the service if needed externally
    connectWebSocketService() {
      webSocketService.connect();
    },
    disconnectWebSocketService() {
      webSocketService.disconnect();
    }
  })),
);

6. Update ProductCatalogComponent to initiate real-time updates:

// src/app/products/product-catalog/product-catalog.component.ts (Updated for Real-time)
import { ChangeDetectionStrategy, Component, inject, OnInit, OnDestroy } from '@angular/core'; // Import OnDestroy
import { CommonModule, NgFor, NgIf } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store'; // Only needed if dispatching connect/disconnect actions for effects
import * as ProductActions from '../product.actions'; // For dispatching
import * as CategoryActions from '../../categories/category.actions';
import { Product } from '../../models/product.model';
import { Category } from '../../models/category.model';
// ... other selectors (adjust based on traditional vs Signal Store for products)
import { Observable, Subscription } from 'rxjs'; // Import Subscription
import { withLatestFrom, map } from 'rxjs/operators';
import { ProductStore } from '../product.store'; // Import ProductStore if using Signal Store

interface ProductWithCategory extends Product {
    category: Category | undefined;
}

@Component({
  selector: 'app-product-catalog',
  standalone: true,
  imports: [CommonModule, FormsModule, NgFor, NgIf],
  template: `
    <div class="product-catalog-container">
      <h2>Product Catalog (Real-time Updates)</h2>

      <div *ngIf="(productStore?.isLoading() || categoryIsLoading$ | async)">Loading...</div>
      <div *ngIf="(productStore?.error() || categoryError$ | async) as error" style="color: red;">Error: {{ error }}</div>

      <div class="controls">
        <input type="text" [(ngModel)]="searchTerm" (input)="onSearchChange()" placeholder="Search products">
        <select [(ngModel)]="selectedCategoryId" (change)="onCategoryFilterChange()">
          <option [ngValue]="null">All Categories</option>
          <option *ngFor="let category of (allCategories$ | async)" [ngValue]="category.id">{{ category.name }}</option>
        </select>
        <button (click)="onRefreshProducts()">Refresh Products</button>
      </div>

      <button (click)="toggleRealtimeUpdates()">
        {{ isRealtimeActive ? 'Stop Real-time Updates' : 'Start Real-time Updates' }}
      </button>

      <!-- Add New Product, Edit Product forms -->

      <ul class="product-list">
        <li *ngFor="let product of (filteredProducts$ | async); trackBy: trackById">
          <div>
            <strong>{{ product.name }}</strong> - \${{ product.price }}
            <p>{{ product.description }}</p>
            <small>Category: {{ product.category?.name || 'N/A' }}</small>
            <span *ngIf="isRealtimeActive && product.name.includes('(Live Update)')" style="color: blue; font-size: 0.8em; margin-left: 5px;">(LIVE)</span>
          </div>
          <div class="actions">
            <button (click)="editProduct(product)">Edit</button>
            <button (click)="deleteProduct(product.id)">Delete</button>
          </div>
        </li>
      </ul>

      <div *ngIf="productStore.selectedProduct() as selectedProduct" class="edit-product-form">
        <h3>Edit Product: {{ selectedProduct.name }}</h3>
        <input type="text" [(ngModel)]="editedProduct.name">
        <input type="number" [(ngModel)]="editedProduct.price">
        <textarea [(ngModel)]="editedProduct.description"></textarea>
        <select [(ngModel)]="editedProduct.categoryId">
          <option [ngValue]="null" disabled>Select Category</option>
          <option *ngFor="let category of (allCategories$ | async)" [ngValue]="category.id">{{ category.name }}</option>
        </select>
        <button (click)="saveEditedProduct()">Save Changes</button>
        <button (click)="cancelEdit()">Cancel</button>
      </div>
    </div>
  `,
  styles: [`/* ... styles ... */`],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductCatalogComponent implements OnInit, OnDestroy {
  // Use Signal Store for products
  readonly productStore = inject(ProductStore);
  private store = inject(Store); // For dispatching category actions

  searchTerm: string = '';
  selectedCategoryId: string | null = null;

  newProductName: string = '';
  newProductPrice: number = 0;
  newProductDescription: string = '';
  newProductCategoryId: string | null = null;

  editedProduct: Product = { id: '', name: '', price: 0, description: '', categoryId: '' };

  allCategories$: Observable<Category[]> = this.store.select(selectAllCategories);
  categoryIsLoading$: Observable<boolean> = this.store.select(selectCategoryIsLoading);
  categoryError$: Observable<string | null> = this.store.select(selectCategoryError);

  isRealtimeActive: boolean = false;
  private realtimeSubscription: Subscription | null = null; // For manual subscription if using rxMethod

  // Combine productStore.filteredProducts with categories
  filteredProducts$: Observable<ProductWithCategory[]> = this.productStore.filteredProducts.pipe(
      withLatestFrom(this.store.select(selectCategoryEntities)),
      map(([products, categoryEntities]) => {
          return products.map(product => ({
              ...product,
              category: categoryEntities[product.categoryId]
          }));
      })
  );


  constructor() {
    this.productStore.selectedProduct().subscribe(product => {
      if (product) {
        this.editedProduct = { ...product };
      }
    });
  }

  ngOnInit(): void {
    this.productStore.loadProducts();
    this.store.dispatch(CategoryActions.loadCategories());
  }

  ngOnDestroy(): void {
    // If using rxMethod's internal takeUntil, this might not be strictly necessary
    // but good practice for manual subscriptions
    if (this.isRealtimeActive) {
        // Option 1: Dispatch action for effects
        // this.store.dispatch(ProductActions.disconnectProductWebSocket());
        // Option 2: Call Signal Store method
        this.productStore.disconnectWebSocketService();
    }
    this.realtimeSubscription?.unsubscribe();
  }

  onSearchChange(): void {
    this.productStore.setSearchTerm(this.searchTerm);
  }

  onCategoryFilterChange(): void {
    this.productStore.setCategory(this.selectedCategoryId);
  }

  onRefreshProducts(): void {
    this.productStore.loadProducts();
    this.store.dispatch(CategoryActions.loadCategories());
  }

  toggleRealtimeUpdates(): void {
      this.isRealtimeActive = !this.isRealtimeActive;
      if (this.isRealtimeActive) {
          // Option 1: Dispatch action for effects
          // this.store.dispatch(ProductActions.connectProductWebSocket());
          // Option 2: Call Signal Store method
          this.productStore.connectWebSocketService();
          this.productStore.startRealtimeUpdates();
      } else {
          // Option 1: Dispatch action for effects
          // this.store.dispatch(ProductActions.disconnectProductWebSocket());
          // Option 2: Call Signal Store method
          this.productStore.disconnectWebSocketService();
      }
  }

  addNewProduct(): void {
    if (this.newProductName && this.newProductPrice > 0 && this.newProductDescription && this.newProductCategoryId) {
      const newProduct: Omit<Product, 'id'> = {
        name: this.newProductName,
        price: this.newProductPrice,
        description: this.newProductDescription,
        categoryId: this.newProductCategoryId,
      };
      this.productStore.addProduct(newProduct);
      this.clearNewProductForm();
    }
  }

  editProduct(product: ProductWithCategory): void {
    this.productStore.selectProduct(product.id);
  }

  saveEditedProduct(): void {
    if (this.editedProduct.id && this.editedProduct.name && this.editedProduct.price > 0 && this.editedProduct.description && this.editedProduct.categoryId) {
      this.productStore.updateProduct({ id: this.editedProduct.id, changes: this.editedProduct });
      this.productStore.selectProduct(null);
    }
  }

  cancelEdit(): void {
    this.productStore.selectProduct(null);
  }

  deleteProduct(id: string): void {
    if (confirm('Are you sure you want to delete this product?')) {
      this.productStore.deleteProduct(id);
    }
  }

  private clearNewProductForm(): void {
    this.newProductName = '';
    this.newProductPrice = 0;
    this.newProductDescription = '';
    this.newProductCategoryId = null;
  }

  trackById(index: number, product: ProductWithCategory): string {
    return product.id;
  }
}

Run the application and test:

  1. Click “Start Real-time Updates”.
  2. Observe the product list. Every few seconds, one of the mock products should update its name (with “(Live Update)”) and price, reflecting the real-time data push.
  3. Click “Stop Real-time Updates” to stop the stream.

Exercises/Mini-Challenges

  1. Implement automatic reconnection:
    • Modify WebSocketService to attempt to reconnect after a delay if the WebSocket connection unexpectedly closes or errors.
    • Consider exponential backoff for reconnection attempts.
    • Your connectWebSocket$ effect might need adjustments to handle this reconnection logic.
  2. Handle different real-time message types:
    • Extend WebSocketService.createMockWebSocketStream to emit different types of messages (e.g., productAddedRealtime, productDeletedRealtime).
    • Adjust ProductRealtimeEffects.connectWebSocket$ to map these to their respective NgRx actions and update the state accordingly.

9. Offline First with NgRx: State Persistence

Building an “offline-first” application means designing it to function effectively even when there’s no network connection, gracefully synchronizing data when connectivity is restored. NgRx provides a solid foundation for achieving this through state persistence.

Detailed Explanation

The core idea is to save your NgRx store state to local storage (or another client-side storage mechanism) and rehydrate it when the application starts.

Key considerations:

  1. Storage Mechanism:
    • localStorage: Simple, synchronous, good for small amounts of data.
    • sessionStorage: Similar to localStorage but cleared when the session ends.
    • IndexedDB: Asynchronous, more powerful, suitable for larger, structured data.
  2. Serialization/Deserialization: Convert state objects to strings for storage (JSON.stringify) and back (JSON.parse).
  3. State Hydration: Load the saved state into the NgRx store when the application initializes.
  4. State Synchronization (Offline-Online): This is the more complex part.
    • Optimistic UI: When a user performs an action offline (e.g., adds a product), update the UI immediately with the assumption it will succeed, but queue the action.
    • Action Queue: Store offline actions in a queue. When online, dispatch these queued actions in order.
    • Conflict Resolution: Handle scenarios where local changes conflict with server-side changes made by others.

For this guide, we’ll focus on basic state persistence (saving and loading from localStorage). Full offline-first with action queues and conflict resolution is a much larger topic.

Code Examples

Let’s implement basic state persistence for our Product and Auth state using localStorage.

1. Create a localStorage service:

// src/app/shared/storage/local-storage.service.ts
import { Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class LocalStorageService {
  getItem<T>(key: string): T | null {
    try {
      const item = localStorage.getItem(key);
      return item ? JSON.parse(item) : null;
    } catch (error) {
      console.error('Error reading from localStorage', error);
      return null;
    }
  }

  setItem<T>(key: string, value: T): void {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch (error) {
      console.error('Error writing to localStorage', error);
    }
  }

  removeItem(key: string): void {
    try {
      localStorage.removeItem(key);
    } catch (error) {
      console.error('Error removing from localStorage', error);
    }
  }
}

2. Create an NgRx meta-reducer for state persistence:

Meta-reducers are higher-order reducers that wrap your main reducer, allowing you to intercept actions or modify the state before or after the main reducer runs.

// src/app/shared/storage/hydration.reducer.ts
import { ActionReducer, INIT, UPDATE } from '@ngrx/store';
import { LocalStorageService } from './local-storage.service';
import { AuthState } from '../../auth/auth.reducer';
import { ProductState } from '../../products/product.reducer'; // If using traditional product store
import { initialProductState } from '../../products/product.reducer';
import { initialAuthState } from '../../auth/auth.reducer';

export interface AppState {
  auth: AuthState;
  product: ProductState; // Include product if using traditional store
  // ... other state slices
}

export function hydrationMetaReducer(
  reducer: ActionReducer<any> // Use 'any' for the root reducer type
): ActionReducer<any> {
  const localStorageService = new LocalStorageService(); // Instantiate directly for meta-reducer

  return (state, action) => {
    // 1. Hydrate state on application initialization
    if (action.type === INIT || action.type === UPDATE) {
      const storedState = localStorageService.getItem<AppState>('app_state');
      if (storedState) {
        console.log('Hydrating state from localStorage:', storedState);
        // Merge stored state with initial state, respecting structure
        return {
            ...state,
            auth: { ...initialAuthState, ...storedState.auth }, // Merge auth state
            product: { ...initialProductState, ...storedState.product } // Merge product state
            // ... merge other slices
        };
      }
    }

    // 2. Let the original reducer handle the action
    const nextState = reducer(state, action);

    // 3. Persist specific parts of the state after every change
    if (nextState.auth) {
        localStorageService.setItem('app_state', { auth: nextState.auth, product: nextState.product });
    }
    // Only persist slices that you explicitly want to be offline
    // You might filter out isLoading, error, etc., to avoid persisting transient UI state

    return nextState;
  };
}

3. Register the meta-reducer in app.config.ts:

// src/app/app.config.ts
import { provideStore, META_REDUCERS } from '@ngrx/store'; // Import META_REDUCERS token
import { hydrationMetaReducer } from './shared/storage/hydration.reducer';

export const appConfig: ApplicationConfig = {
  providers: [
    // ...
    provideStore({
      auth: authReducer,
      product: productReducer, // Use traditional product reducer for this example to show persistence
      category: categoryReducer,
    }),
    { provide: META_REDUCERS, useFactory: () => [hydrationMetaReducer] }, // Register meta-reducer
    // ...
  ]
};

Run the application and test:

  1. Log in as ’test’. Add a few products.
  2. Refresh your browser.
  3. You should see that your login status and the products you added are still there! This is because the state was rehydrated from localStorage.
  4. Open your browser’s DevTools, go to “Application” tab, then “Local Storage”, and inspect app_state. You’ll see your serialized state.

Exercises/Mini-Challenges

  1. Exclude sensitive data from persistence:
    • Modify hydrationMetaReducer to explicitly exclude token from AuthState before saving to localStorage.
    • Security Note: localStorage is not secure for sensitive data like JWT tokens; they can be vulnerable to XSS attacks. For real apps, use HttpOnly cookies. This example is for demonstration only.
  2. Persist only certain actions:
    • Instead of persisting on every state change, modify the meta-reducer to only persist when specific actions are dispatched (e.g., loginSuccess, addProductSuccess). This can be more efficient.
  3. Implement a simple “offline indicator”:
    • Create a simple service that checks navigator.onLine.
    • Create a global NgRx selector that exposes the online/offline status.
    • Display a small indicator in your app.component.ts (e.g., a badge saying “Offline” or “Online”).

Conclusion

Congratulations! You’ve completed a comprehensive journey through advanced NgRx concepts with Angular v20. You’ve now equipped yourself with powerful techniques to build highly performant, scalable, secure, and real-time capable applications.

Remember that the landscape of NgRx and Angular is always evolving. Stay curious, keep experimenting, and always refer to the official documentation for the latest best practices and features.

The skills you’ve gained in advanced effect management, state normalization, performance optimization, rigorous testing, and integration with real-time and offline capabilities will serve you well in tackling the most challenging front-end development projects. Happy coding!