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:
- Inject the NgRx Store: To access the application state.
- Select Authentication State: Use NgRx selectors to get the
isLoggedInstatus or user roles from the store. - Pipe with RxJS Operators: Use RxJS operators like
take(1)to ensure the observable completes after emitting once, andmaportapto perform conditional logic. - Redirect (if necessary): If the user is not authorized, redirect them to a login page or an unauthorized access page using the
Routerservice.
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)andinject(Router): Used to get instances ofStoreandRouterin a functional context.store.select(selectIsLoggedIn).pipe(take(1), ...): SelectsisLoggedInand ensures the observable completes immediately.map(isLoggedIn => ...): Transforms theisLoggedInboolean. Iftrue, returnstrue(allow access). Iffalse, returns aUrlTreefor redirection.router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } }): Constructs aUrlTreethat redirects to/loginand 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 aroleproperty on theUserobject.
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:
- Navigate to
/productsor/dashboard. You should be redirected to/login. - Log in with
username: test,password: password. You should be redirected back to/products. - Now try navigating to
/dashboard. If you logged in astest, you’ll be redirected to/unauthorized. - Logout, then login as
username: admin,password: password. Now/dashboardshould be accessible.
Exercises/Mini-Challenges
- Enhance
adminGuard:- Modify the
Usermodel to include aroles: string[]property. - Update
AuthService.loginto assign['user']totestuser and['user', 'admin']toadminuser. - Adjust
adminGuardto check ifuser.rolesarray includes'admin'.
- Modify the
- Add a
CanDeactivateguard:- Create a new guard
unsavedChangesGuardthat 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 likecanDeactivate(): booleanwhich the guard would call. - Apply this guard to your product edit form route (if you extract it to its own route).
- Create a new guard
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:
- Observe how quickly new products are added (using
concatMap). - Try updating a product multiple times rapidly. Notice how
exhaustMapensures only one update request is in flight. - Type quickly into the search box.
debounceTimeanddistinctUntilChangedshould prevent excessive API calls. - Switch categories. Notice how
switchMapensures only the latest filter request is considered.
Exercises/Mini-Challenges
- Implement a “save with confirmation” effect:
- Create an action
saveProductConfirmedthat is dispatched after a user confirms an action (e.g., a modal asks “Are you sure?”). - Create an effect
confirmAndSaveProduct$that listens for asaveProductaction, then opens a confirmation dialog (simulate with aconfirm()call or a service). If confirmed, dispatchsaveProductConfirmed. - Modify your existing
updateProduct$effect to only respond tosaveProductConfirmed. This uses a common pattern of “action splitting” to manage complex user interactions.
- Create an action
- Chaining Effects:
- Create a scenario where a
loginSuccessaction (from your AuthEffects) automatically triggers aloadUserPreferencesaction (for a newUserPreferencesEffects). This demonstrates how effects can communicate by dispatching actions that other effects listen to.
- Create a scenario where a
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.
- Add, edit, delete products.
- Observe the Redux DevTools: You’ll see
ngrx/dataactions being dispatched (e.g.,@ngrx/data/query-all/success,@ngrx/data/add-one/success).
Exercises/Mini-Challenges
- Customize
ngrx/datafor specific API endpoints:- Imagine your “add product” API endpoint is
/api/products/createinstead of just/api/products. - Explore
withHttpResourceUrlsinapp.config.tsto map theaddoperation forProductto this custom URL.
- Imagine your “add product” API endpoint is
- Add a custom selector for
ngrx/data:- Create a custom selector that returns only products above a certain price.
- Explore
EntitySelectorsFactoryto create custom selectors for yourProductEntityService.
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:
- Each type of entity gets its own “table” (an object where keys are entity IDs).
- References are used instead of embedding data.
- 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:
- Observe the Redux DevTools. You’ll see
productandcategorystate slices each managed withidsandentities. - Add products, select categories for new products, filter by category.
- Note how
selectProductsWithCategoriescombines data from both normalized state slices into a denormalized view for the UI.
Exercises/Mini-Challenges
- Add a
selectProductandclearSelectedProductaction/reducer logic:- Implement these in
product.actions.tsandproduct.reducer.tsto properly manageselectedProductIdin theProductState. - Modify
ProductCatalogComponentto use these actions.
- Implement these in
- Add a
tagsentity and link to products:- Create a
Tagmodel and aTagNgRx module (actions, reducer, selectors, effects). - Add
tagIds: string[]to theProductmodel. - Update relevant services and components to display and manage tags on products. This will further solidify your understanding of many-to-many relationships.
- Create a
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.aemitsloadProductsimmediately (after 1 “frame” of-).cold('--b|', { b: mockProducts }): Creates a “cold” observable for the service response.bemitsmockProductsafter 2 frames, then completes.productService.getProducts.and.returnValue(response): Mocks the service call to return thecoldobservable.expectObservable(effects.loadProducts$).toBe(expected, { ... });: Asserts that the output ofeffects.loadProducts$matches theexpectedmarble diagram and the expected values (c).
The TestScheduler takes practice, but it’s an indispensable tool for reliable asynchronous testing.
Exercises/Mini-Challenges
- Test
updateProduct$effect:- Write a
TestSchedulertest forupdateProduct$usingexhaustMap. - Simulate dispatching
updateProducttwice very quickly. Verify that only the first one triggers a service call and the second one is ignored.
- Write a
- Test an effect that uses
withLatestFrom:- If you have an effect that uses
withLatestFrom(this.store.select(someSelector)), ensure you configureprovideMockStorewith 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.
- If you have an effect that uses
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
OnPushChange Detection Strategy:- By default, Angular components use
Defaultchange detection, meaning they are checked for changes frequently (on every event, timer, HTTP response). OnPushstrategy 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
asyncpipe emits a new value. ChangeDetectorRef.detectChanges()orChangeDetectorRef.markForCheck()is explicitly called.
- Using
OnPushwidely 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.
- By default, Angular components use
- Memoized Selectors:
- NgRx
createSelectorcreates 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.
- NgRx
- Immutability:
- As discussed earlier, always return new objects/arrays when updating state. This is fundamental for
OnPushto work effectively and for selectors to re-memoize correctly.
- As discussed earlier, always return new objects/arrays when updating state. This is fundamental for
- Minimizing
asyncpipes:- While
asyncpipe is great, too many in a single template can still trigger multiple subscriptions. Consider using a singleasyncpipe at the top level and then destructuring the state, or using*ngIf="state$ | async as state"to bind it once.
- While
- 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.
- When dispatching actions or using
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
- Refactor with
component-store(or NgRx Signal Store for local state):- For the search term and selected category, instead of using
ngModeland component properties, create a smallComponentStore(or a localSignalStore) withinProductCatalogComponentto managesearchTermandselectedCategoryId. - This keeps the component’s internal state isolated and leverages NgRx patterns even for local data.
- For the search term and selected category, instead of using
- 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
OnPushcomponents are only checked when relevant inputs change or events occur. - Compare this to removing
ChangeDetectionStrategy.OnPushfromProductCatalogComponent.
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:
- WebSocket Service: A service responsible for establishing and maintaining the WebSocket connection, sending messages, and exposing an RxJS
Observablefor incoming messages. - 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
mergeMaporswitchMapto 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.
- Listen for incoming WebSocket messages (using
- It then uses RxJS operators to:
- Reducers: Update the state based on the real-time actions.
- 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:
- Click “Start Real-time Updates”.
- 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.
- Click “Stop Real-time Updates” to stop the stream.
Exercises/Mini-Challenges
- Implement automatic reconnection:
- Modify
WebSocketServiceto 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.
- Modify
- Handle different real-time message types:
- Extend
WebSocketService.createMockWebSocketStreamto 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.
- Extend
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:
- Storage Mechanism:
localStorage: Simple, synchronous, good for small amounts of data.sessionStorage: Similar tolocalStoragebut cleared when the session ends.IndexedDB: Asynchronous, more powerful, suitable for larger, structured data.
- Serialization/Deserialization: Convert state objects to strings for storage (
JSON.stringify) and back (JSON.parse). - State Hydration: Load the saved state into the NgRx store when the application initializes.
- 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:
- Log in as ’test’. Add a few products.
- Refresh your browser.
- You should see that your login status and the products you added are still there! This is because the state was rehydrated from
localStorage. - 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
- Exclude sensitive data from persistence:
- Modify
hydrationMetaReducerto explicitly excludetokenfromAuthStatebefore saving tolocalStorage. - Security Note:
localStorageis not secure for sensitive data like JWT tokens; they can be vulnerable to XSS attacks. For real apps, useHttpOnlycookies. This example is for demonstration only.
- Modify
- 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.
- Instead of persisting on every state change, modify the meta-reducer to only persist when specific actions are dispatched (e.g.,
- 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”).
- Create a simple service that checks
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!