Mastering NgRx with Angular v20: A Comprehensive Beginner’s Guide
Welcome, aspiring Angular developer! If you’ve found your way here, you’re likely eager to tame the complexities of state management in your Angular applications. You’ve chosen an excellent time to dive in, as Angular v20 and NgRx v20 bring powerful new features and refinements that make building robust and scalable applications more approachable than ever.
This document is your complete guide to understanding and implementing NgRx, specifically focusing on its latest iteration and how it harmonizes with Angular v20. We’ll start from the very basics, explaining why state management is crucial, then gradually build up your knowledge with clear explanations, practical code examples, and hands-on exercises. By the end, you’ll not only grasp the core concepts but also be equipped to apply NgRx effectively in real-world projects.
Let’s begin our journey into the exciting world of reactive state management with NgRx and Angular!
1. Introduction to NgRx
What is NgRx?
NgRx is a group of libraries that provide reactive state management for Angular applications, inspired by the Redux pattern. At its core, NgRx helps you manage the state of your application in a predictable and consistent way. Think of your application’s state as all the data it needs to function—user information, lists of items, application settings, and so on. As applications grow, managing this state can become a complex and error-prone task.
NgRx provides a centralized store that holds the entire application state. All changes to this state happen through a single, well-defined flow, making your application easier to understand, debug, and test.
In NgRx v20, a significant focus has been placed on the NgRx Signal Store, which leverages Angular’s new Signals API. This offers a more lightweight and integrated approach to state management, particularly for local and feature-specific states, while still providing powerful tools for global state.
Why Learn NgRx? (Benefits, Use Cases, Industry Relevance)
Learning NgRx brings a multitude of benefits, especially in modern, complex Angular applications:
- Predictable State: NgRx enforces a strict, unidirectional data flow, making it clear how and when your application’s state changes. This predictability drastically reduces bugs and makes your application easier to reason about.
- Centralized State: All your application’s state lives in a single, immutable object. This single source of truth simplifies debugging and provides a consistent experience across your application.
- Debuggability: With tools like the Redux DevTools Extension (which integrates seamlessly with NgRx), you can time-travel debug your application, inspect every state change, and replay actions. This is invaluable for pinpointing issues.
- Performance: By using memoized selectors and adhering to the Redux pattern, NgRx can help optimize change detection in your Angular application, leading to better performance. NgRx v20, especially with the Signal Store, further enhances this by integrating with Angular’s reactive Signals API for fine-grained updates.
- Scalability: For large-scale applications with many features and complex data interactions, NgRx provides a structured and maintainable architecture that prevents state-related spaghetti code.
- Testability: The pure functions (reducers, selectors) that form the core of NgRx are inherently easy to unit test, making your application more robust.
- Community & Ecosystem: NgRx has a vibrant community and a rich ecosystem of tools and extensions, including schematics, entity management, and more.
Common Use Cases:
- Complex Forms: Managing multi-step forms with interdependent fields.
- Data-heavy Applications: Applications that fetch, cache, and display large amounts of data (e.g., e-commerce, dashboards, content management systems).
- Real-time Applications: Integrating with WebSockets for real-time updates.
- User Authentication and Authorization: Managing user login state, tokens, and permissions across the application.
- Offline Support: Storing data locally for offline access.
A Brief History
NgRx emerged from the popularity of Redux in the React ecosystem. As Angular evolved, the need for a robust, opinionated state management solution became evident. NgRx adopted the principles of Redux, tailoring them for Angular’s reactive programming paradigm (RxJS).
Over the years, NgRx has matured, introducing various modules like ngrx/effects for handling side effects, ngrx/entity for managing collections of entities, and ngrx/component-store for local component state. With Angular’s introduction of Signals, NgRx v20 has seen a significant evolution with the NgRx Signal Store, aiming to provide a more lightweight and ergonomic state management solution that aligns with modern Angular reactivity. This release also introduces an experimental Events plugin for SignalStore, bringing Redux-style event-driven architecture to a more lightweight context.
Setting up your Development Environment
Before we dive into NgRx, let’s ensure your Angular development environment is ready.
Prerequisites:
- Node.js (v20.19 or later): Angular v20 requires Node.js v20.19 or later. You can download it from nodejs.org. To check your version, open your terminal or command prompt and run:
node -v - npm (Node Package Manager) or Yarn/Bun: npm is installed automatically with Node.js. You can check its version:Alternatively, you can use Yarn or Bun for package management. For this guide, we’ll primarily use
npm -vnpm. - Angular CLI (v20 or later): The Angular CLI is a command-line interface tool that you use to initialize, develop, scaffold, and maintain Angular applications. Install it globally:Verify the installation:
npm install -g @angular/cli@20ng version - Integrated Development Environment (IDE): A code editor like Visual Studio Code is highly recommended. Install extensions for Angular, TypeScript, and ESLint for a better development experience.
Step-by-step instructions to set up an Angular v20 project:
Create a new Angular project: Open your terminal and run:
ng new my-ngrx-app --standalone --routing --style=cssmy-ngrx-app: The name of your application.--standalone: This flag generates an application with standalone components by default, which is the recommended approach in Angular v20.--routing: Adds Angular routing to the project.--style=css: Sets the default stylesheet format to CSS. You can choosescss,sass, orlessif you prefer.
The CLI will ask if you’d like to enable Server-Side Rendering (SSR) and Static Site Generation (SSG). For this guide, you can choose “No” for now, as we’ll focus on client-side state management.
Navigate into your project directory:
cd my-ngrx-appServe the application:
ng serve -oThis command compiles and serves your application locally and opens it in your default browser at
http://localhost:4200.
Now that your environment is set up, let’s start with the core concepts of NgRx!
2. Core Concepts and Fundamentals (NgRx Signal Store)
In NgRx v20, the NgRx Signal Store is a primary focus, offering a more streamlined way to manage state, especially for component and feature-specific scenarios. It leverages Angular’s reactive Signals API for fine-grained updates. While the traditional NgRx Store (Actions, Reducers, Effects, Selectors) is still valid and powerful for global, complex state, we will begin with the Signal Store due to its modern approach and alignment with Angular v20.
2.1. The Signal Store: A Simpler Approach
The NgRx Signal Store provides a way to encapsulate state using Signals and expose methods for updating that state and computed signals for deriving values. It’s essentially a service that holds state as signals.
Detailed Explanation
A Signal Store is created using the signalStore function. It’s a collection of state, computed signals, and methods that act upon that state. It’s designed to be highly composable, allowing you to build complex stores from smaller, reusable features.
Code Examples
Let’s create a simple counter Signal Store.
1. Create the store file:
Inside your src/app folder, create a new file counter.store.ts:
// src/app/counter.store.ts
import { computed } from '@angular/core';
import { signalStore, withComputed, withMethods, withState, patchState } from '@ngrx/signals';
// Define the shape of our state
interface CounterState {
count: number;
}
// Define the initial state
const initialState: CounterState = {
count: 0,
};
export const CounterStore = signalStore(
{ providedIn: 'root' }, // Makes the store a singleton and injectable
withState(initialState), // Initializes the state
withComputed(({ count }) => ({ // Defines derived state (computed signals)
doubleCount: computed(() => count() * 2),
isEven: computed(() => count() % 2 === 0),
})),
withMethods((store) => ({ // Defines methods to interact with the state
increment() {
patchState(store, { count: store.count() + 1 });
},
decrement() {
patchState(store, { count: store.count() - 1 });
},
reset() {
patchState(store, { count: 0 });
},
incrementBy(value: number) {
patchState(store, { count: store.count() + value });
},
}))
);
Explanation:
signalStore({ providedIn: 'root' }): Creates the store.providedIn: 'root'makes it a singleton service available throughout your application via dependency injection.withState(initialState): Initializes the state withcount: 0.withComputed(...): Defines derived signals.doubleCountandisEvenwill automatically re-calculate whencount()changes.withMethods(...): Defines functions to update the state.patchState(store, { count: store.count() + 1 }): This is the core function to update the state. It takes the store instance and an object with the properties you want to change. It’s a “patch” because it only updates the specified properties, leaving others as they are.
2. Use the store in a component:
Now, let’s use this CounterStore in an Angular component.
// src/app/app.component.ts
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import { CounterStore } from './counter.store';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, RouterOutlet],
template: `
<div class="container">
<h1>NgRx Signal Store Counter (Angular v20)</h1>
<p>Current Count: {{ counterStore.count() }}</p>
<p>Double Count: {{ counterStore.doubleCount() }}</p>
<p>Is Even: {{ counterStore.isEven() }}</p>
<button (click)="counterStore.increment()">Increment</button>
<button (click)="counterStore.decrement()">Decrement</button>
<button (click)="counterStore.reset()">Reset</button>
<button (click)="counterStore.incrementBy(5)">Increment by 5</button>
</div>
`,
styles: [`
.container {
font-family: Arial, sans-serif;
text-align: center;
margin-top: 50px;
}
button {
padding: 10px 20px;
margin: 5px;
font-size: 16px;
cursor: pointer;
}
`]
})
export class AppComponent {
// Inject the CounterStore
readonly counterStore = inject(CounterStore);
title = 'my-ngrx-app';
}
Explanation:
readonly counterStore = inject(CounterStore);: We inject theCounterStoreusing Angular’sinjectfunction, which is a modern way to access services.{{ counterStore.count() }}: We access thecountsignal directly using its value accessor(). Since signals are reactive, any change tocounterStore.count()will automatically update the UI.counterStore.increment(): We call the methods defined in our store to modify the state.
Exercises/Mini-Challenges
- Add a
multiplyBymethod:- In
counter.store.ts, add a new methodmultiplyBy(value: number). This method should multiply the currentcountby thevaluepassed. - Add a new button in
app.component.htmlthat callscounterStore.multiplyBy(2).
- In
- Add a new computed signal:
- In
counter.store.ts, add aisNegativecomputed signal that returnstrueifcountis less than 0, andfalseotherwise. - Display this
isNegativestatus inapp.component.html.
- In
2.2. Integrating with RxJS (rxMethod)
While the Signal Store is great for synchronous state updates, real-world applications often need to perform asynchronous operations like fetching data from an API. rxMethod from @ngrx/signals/rxjs-interop is designed for this, allowing you to integrate RxJS power directly into your Signal Store methods.
Detailed Explanation
rxMethod creates a method that accepts an observable (or a signal that will be converted to an observable). It handles the subscription and unsubscription automatically, making it ideal for side effects. Inside an rxMethod, you can use RxJS operators like debounceTime, switchMap, tap, and tapResponse (from @ngrx/operators) to manage asynchronous flows.
Code Examples
Let’s enhance our counter store to load an initial count from a fake API after a delay.
1. Update counter.store.ts to include rxMethod:
First, install @ngrx/signals/rxjs-interop and @ngrx/operators:
npm install @ngrx/signals @ngrx/operators @ngrx/signals/rxjs-interop
Then, modify counter.store.ts:
// src/app/counter.store.ts
import { computed, inject } from '@angular/core';
import { signalStore, withComputed, withMethods, withState, patchState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { pipe, switchMap, tap, delay, of } from 'rxjs'; // Import RxJS operators
import { tapResponse } from '@ngrx/operators'; // For handling success/error in effects
// Mock a simple API service
class ApiService {
loadInitialCount() {
console.log('Fetching initial count...');
return of(42).pipe(delay(1000)); // Simulate API call with 1 second delay
}
}
interface CounterState {
count: number;
isLoading: boolean; // Add loading state
error: string | null; // Add error state
}
const initialState: CounterState = {
count: 0,
isLoading: false,
error: null,
};
export const CounterStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withComputed(({ count }) => ({
doubleCount: computed(() => count() * 2),
isEven: computed(() => count() % 2 === 0),
})),
withMethods((store, apiService = inject(ApiService)) => ({ // Inject ApiService
increment() {
patchState(store, { count: store.count() + 1 });
},
decrement() {
patchState(store, { count: store.count() - 1 });
},
reset() {
patchState(store, { count: 0 });
},
incrementBy(value: number) {
patchState(store, { count: store.count() + value });
},
// rxMethod for asynchronous operation
loadCount: rxMethod<void>(
pipe(
tap(() => patchState(store, { isLoading: true, error: null })), // Set loading state
switchMap(() =>
apiService.loadInitialCount().pipe(
tapResponse({
next: (value) => patchState(store, { count: value }), // Update state on success
error: (err: Error) => patchState(store, { error: err.message }), // Set error state on failure
finalize: () => patchState(store, { isLoading: false }), // Always reset loading
})
)
)
)
),
})),
);
Explanation:
ApiService: A simple class to simulate an API call.isLoading,error: Added toCounterStateto track the status of asynchronous operations.apiService = inject(ApiService): TheApiServiceis injected into thewithMethodsfactory.loadCount: rxMethod<void>(...):rxMethod<void>: Declares a method that takes no arguments.tap(() => patchState(store, { isLoading: true, error: null })): Before the API call, setisLoadingtotrueand clear any previous errors.switchMap(() => apiService.loadInitialCount().pipe(...)): WhenloadCount()is called, it triggers theloadInitialCountobservable.switchMapunsubscribes from previous emissions ifloadCountis called again quickly.tapResponse({ next, error, finalize }): A convenient operator from@ngrx/operatorsto handle the different outcomes of an observable:next: On successful data retrieval, updatecount.error: On error, set theerrormessage.finalize: Always resetisLoadingtofalse, regardless of success or error.
2. Update app.component.ts to use loadCount and display loading/error states:
// src/app/app.component.ts
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import { CounterStore } from './counter.store';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, RouterOutlet],
template: `
<div class="container">
<h1>NgRx Signal Store Counter (Angular v20)</h1>
<p>Current Count: {{ counterStore.count() }}</p>
<p>Double Count: {{ counterStore.doubleCount() }}</p>
<p>Is Even: {{ counterStore.isEven() }}</p>
<div *ngIf="counterStore.isLoading()">Loading...</div>
<div *ngIf="counterStore.error()" style="color: red;">Error: {{ counterStore.error() }}</div>
<button (click)="counterStore.increment()">Increment</button>
<button (click)="counterStore.decrement()">Decrement</button>
<button (click)="counterStore.reset()">Reset</button>
<button (click)="counterStore.incrementBy(5)">Increment by 5</button>
<button (click)="counterStore.loadCount()">Load Initial Count (Async)</button>
</div>
`,
styles: [`
.container {
font-family: Arial, sans-serif;
text-align: center;
margin-top: 50px;
}
button {
padding: 10px 20px;
margin: 5px;
font-size: 16px;
cursor: pointer;
}
`]
})
export class AppComponent implements OnInit {
readonly counterStore = inject(CounterStore);
title = 'my-ngrx-app';
ngOnInit() {
// Optionally load count when component initializes
// this.counterStore.loadCount();
}
}
Exercises/Mini-Challenges
- Add an
updateCountAsyncmethod:- Create a new
rxMethodincounter.store.tscalledupdateCountAsync(newValue: number). This method should simulate an API call that “saves” the new value after 2 seconds. - It should set
isLoadingtotrueanderrortonullinitially. - On success,
patchStatewith thenewValue. On error, set an appropriate error message. - Add an input field and a button in
app.component.htmlto allow the user to type a number and callupdateCountAsyncwith that number.
- Create a new
- Handle distinct values and debounce:
- Modify
updateCountAsyncto usedebounceTime(500)anddistinctUntilChanged()(from RxJS) so that the API call is only made if the value hasn’t changed for 500ms and is different from the previous valid value. This is crucial for search inputs or frequent updates.
- Modify
2.3. Entity Management (@ngrx/signals/entities)
Managing collections of data (entities) like a list of users, products, or tasks is a common task. @ngrx/signals/entities provides powerful utilities to simplify this, offering a standardized way to handle CRUD operations on entity collections within a Signal Store.
Detailed Explanation
The withEntities feature from @ngrx/signals/entities automatically adds state properties like entities (a dictionary of entities by ID) and ids (an array of entity IDs), along with a set of methods for managing them (e.g., addEntity, removeEntity, updateEntity, setAllEntities).
NgRx v20 introduces prependEntity and upsertEntity for more fine-grained control over entity collections.
Code Examples
Let’s create a simple todo list using entity management.
1. Define the Todo interface:
// src/app/models/todo.model.ts
export interface Todo {
id: string;
title: string;
completed: boolean;
}
2. Create the TodosStore:
// src/app/todos.store.ts
import { computed, inject } from '@angular/core';
import { signalStore, withComputed, withMethods, withState, patchState } from '@ngrx/signals';
import { withEntities, addEntity, removeEntity, updateEntity, setAllEntities, prependEntity, upsertEntity } from '@ngrx/signals/entities';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { pipe, switchMap, tap, delay, of } from 'rxjs';
import { tapResponse } from '@ngrx/operators';
import { Todo } from './models/todo.model';
// Mock a simple Todo API service
class TodoApiService {
private nextId = 1;
private todos: Todo[] = [
{ id: '1', title: 'Learn NgRx Signal Store', completed: false },
{ id: '2', title: 'Build a Todo App', completed: true },
];
getTodos() {
console.log('Fetching todos...');
return of([...this.todos]).pipe(delay(500));
}
addTodo(title: string) {
console.log('Adding todo:', title);
const newTodo: Todo = { id: (this.nextId++).toString(), title, completed: false };
this.todos.push(newTodo);
return of(newTodo).pipe(delay(300));
}
updateTodo(updatedTodo: Todo) {
console.log('Updating todo:', updatedTodo.id);
const index = this.todos.findIndex(t => t.id === updatedTodo.id);
if (index > -1) {
this.todos[index] = { ...this.todos[index], ...updatedTodo };
}
return of(this.todos[index]).pipe(delay(300));
}
deleteTodo(id: string) {
console.log('Deleting todo:', id);
this.todos = this.todos.filter(t => t.id !== id);
return of(id).pipe(delay(300));
}
}
interface TodosState {
isLoading: boolean;
error: string | null;
filter: 'all' | 'active' | 'completed'; // Add a filter state
}
const initialState: TodosState = {
isLoading: false,
error: null,
filter: 'all',
};
export const TodosStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withEntities<Todo>(), // Add entity management for Todo type
withComputed(({ entities, filter }) => ({
// Filtered todos based on the current filter state
filteredTodos: computed(() => {
const allTodos = Object.values(entities()); // Convert entities object to array
switch (filter()) {
case 'active': return allTodos.filter(todo => !todo.completed);
case 'completed': return allTodos.filter(todo => todo.completed);
default: return allTodos;
}
}),
totalTodos: computed(() => Object.keys(entities()).length),
activeTodosCount: computed(() => Object.values(entities()).filter(todo => !todo.completed).length),
})),
withMethods((store, todoApiService = inject(TodoApiService)) => ({
setFilter(filter: 'all' | 'active' | 'completed') {
patchState(store, { filter });
},
loadAllTodos: rxMethod<void>(
pipe(
tap(() => patchState(store, { isLoading: true, error: null })),
switchMap(() =>
todoApiService.getTodos().pipe(
tapResponse({
next: (todos) => patchState(store, setAllEntities(todos)), // Use setAllEntities
error: (err: Error) => patchState(store, { error: err.message }),
finalize: () => patchState(store, { isLoading: false }),
})
)
)
)
),
addTodo: rxMethod<string>( // Takes the title as an argument
pipe(
tap(() => patchState(store, { isLoading: true, error: null })),
switchMap((title) =>
todoApiService.addTodo(title).pipe(
tapResponse({
next: (todo) => patchState(store, addEntity(todo)), // Use addEntity
error: (err: Error) => patchState(store, { error: err.message }),
finalize: () => patchState(store, { isLoading: false }),
})
)
)
)
),
updateTodoStatus: rxMethod<{ id: string; completed: boolean }>(
pipe(
tap(() => patchState(store, { isLoading: true, error: null })),
switchMap(({ id, completed }) =>
todoApiService.updateTodo({ id, completed, title: '' }).pipe( // Only send necessary updates
tapResponse({
next: (updatedTodo) => patchState(store, updateEntity({ id: updatedTodo.id, changes: { completed: updatedTodo.completed } })), // Use updateEntity
error: (err: Error) => patchState(store, { error: err.message }),
finalize: () => patchState(store, { isLoading: false }),
})
)
)
)
),
deleteTodo: rxMethod<string>( // Takes the ID as an argument
pipe(
tap(() => patchState(store, { isLoading: true, error: null })),
switchMap((id) =>
todoApiService.deleteTodo(id).pipe(
tapResponse({
next: (deletedId) => patchState(store, removeEntity(deletedId)), // Use removeEntity
error: (err: Error) => patchState(store, { error: err.message }),
finalize: () => patchState(store, { isLoading: false }),
})
)
)
)
),
// New NgRx v20 methods
addTodoToStart: rxMethod<string>(
pipe(
tap(() => patchState(store, { isLoading: true, error: null })),
switchMap((title) =>
todoApiService.addTodo(title).pipe(
tapResponse({
next: (todo) => patchState(store, prependEntity(todo)), // Use prependEntity
error: (err: Error) => patchState(store, { error: err.message }),
finalize: () => patchState(store, { isLoading: false }),
})
)
)
)
),
addOrUpdateTodo: rxMethod<Todo>(
pipe(
tap(() => patchState(store, { isLoading: true, error: null })),
switchMap((todo) =>
todoApiService.updateTodo(todo).pipe( // Assuming updateTodo can also handle new entities based on ID
tapResponse({
next: (res) => patchState(store, upsertEntity(res)), // Use upsertEntity
error: (err: Error) => patchState(store, { error: err.message }),
finalize: () => patchState(store, { isLoading: false }),
})
)
)
)
),
})),
);
Explanation:
TodoApiService: A mock service for CRUD operations onTodoitems.withEntities<Todo>(): This is the key. It sets up the entity management. It requires the type of entity (Todo).setAllEntities(todos): Used inloadAllTodosto replace the entire collection.addEntity(todo): Used inaddTodoto add a single new entity.updateEntity({ id: updatedTodo.id, changes: { completed: updatedTodo.completed } }): Used inupdateTodoStatusto update an existing entity by ID with specific changes.removeEntity(deletedId): Used indeleteTodoto remove an entity by ID.prependEntity(todo)(NgRx v20): Adds an entity to the beginning of the collection. Useful for feeds.upsertEntity(todo)(NgRx v20): Adds an entity if it doesn’t exist, or merges the provided properties if it does.
3. Create the TodoListComponent to use the TodosStore:
// src/app/todo-list/todo-list.component.ts
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule, NgFor, NgIf } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TodosStore } from '../todos.store';
import { Todo } from '../models/todo.model';
@Component({
selector: 'app-todo-list',
standalone: true,
imports: [CommonModule, FormsModule, NgFor, NgIf],
template: `
<div class="todo-container">
<h2>Todo List (NgRx Signal Store)</h2>
<div *ngIf="todosStore.isLoading()">Loading todos...</div>
<div *ngIf="todosStore.error()" style="color: red;">Error: {{ todosStore.error() }}</div>
<div class="filter-buttons">
<button (click)="todosStore.setFilter('all')" [class.active]="todosStore.filter() === 'all'">All ({{ todosStore.totalTodos() }})</button>
<button (click)="todosStore.setFilter('active')" [class.active]="todosStore.filter() === 'active'">Active ({{ todosStore.activeTodosCount() }})</button>
<button (click)="todosStore.setFilter('completed')" [class.active]="todosStore.filter() === 'completed'">Completed</button>
</div>
<div class="add-todo">
<input type="text" [(ngModel)]="newTodoTitle" placeholder="Add new todo">
<button (click)="addTodo()">Add Todo</button>
<button (click)="addTodoToStart()">Add to Start</button>
</div>
<ul class="todo-list">
<li *ngFor="let todo of todosStore.filteredTodos(); trackBy: trackById">
<input
type="checkbox"
[checked]="todo.completed"
(change)="toggleTodoStatus(todo)"
>
<span [class.completed]="todo.completed">{{ todo.title }}</span>
<button class="delete-button" (click)="deleteTodo(todo.id)">Delete</button>
<button class="edit-button" (click)="editTodo(todo)">Edit</button>
</li>
</ul>
<div *ngIf="isEditing">
<h3>Edit Todo</h3>
<input type="text" [(ngModel)]="editedTodo!.title">
<button (click)="saveEditedTodo()">Save</button>
<button (click)="cancelEdit()">Cancel</button>
</div>
</div>
`,
styles: [`
.todo-container {
max-width: 600px;
margin: 50px auto;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
font-family: Arial, sans-serif;
}
.filter-buttons button { margin: 5px; padding: 8px 15px; cursor: pointer; border-radius: 4px; border: 1px solid #ccc; background-color: #f0f0f0; }
.filter-buttons button.active { background-color: #007bff; color: white; border-color: #007bff; }
.add-todo { margin-top: 20px; display: flex; gap: 10px; }
.add-todo input { flex-grow: 1; padding: 8px; border-radius: 4px; border: 1px solid #ccc; }
.add-todo button { padding: 8px 15px; background-color: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer; }
.todo-list { list-style: none; padding: 0; margin-top: 20px; }
.todo-list li {
display: flex;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid #eee;
}
.todo-list li:last-child { border-bottom: none; }
.todo-list li input[type="checkbox"] { margin-right: 10px; }
.todo-list li span { flex-grow: 1; }
.todo-list li span.completed { text-decoration: line-through; color: #888; }
.delete-button { background-color: #dc3545; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer; margin-left: 10px; }
.edit-button { background-color: #ffc107; color: black; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer; margin-left: 5px; }
`]
})
export class TodoListComponent implements OnInit {
readonly todosStore = inject(TodosStore);
newTodoTitle = '';
isEditing = false;
editedTodo: Todo | null = null;
ngOnInit() {
this.todosStore.loadAllTodos(); // Load todos on component initialization
}
addTodo() {
if (this.newTodoTitle.trim()) {
this.todosStore.addTodo(this.newTodoTitle.trim());
this.newTodoTitle = '';
}
}
addTodoToStart() {
if (this.newTodoTitle.trim()) {
this.todosStore.addTodoToStart(this.newTodoTitle.trim());
this.newTodoTitle = '';
}
}
toggleTodoStatus(todo: Todo) {
this.todosStore.updateTodoStatus({ id: todo.id, completed: !todo.completed });
}
deleteTodo(id: string) {
this.todosStore.deleteTodo(id);
}
editTodo(todo: Todo) {
this.isEditing = true;
this.editedTodo = { ...todo }; // Create a copy to avoid direct mutation
}
saveEditedTodo() {
if (this.editedTodo && this.editedTodo.title.trim()) {
this.todosStore.addOrUpdateTodo(this.editedTodo); // Use upsert to save changes
this.isEditing = false;
this.editedTodo = null;
}
}
cancelEdit() {
this.isEditing = false;
this.editedTodo = null;
}
trackById(index: number, todo: Todo): string {
return todo.id;
}
}
4. Include TodoListComponent in app.component.ts:
// src/app/app.component.ts
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import { CounterStore } from './counter.store';
import { TodoListComponent } from './todo-list/todo-list.component'; // Import TodoListComponent
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, RouterOutlet, TodoListComponent], // Add TodoListComponent here
template: `
<div class="container">
<app-todo-list></app-todo-list>
</div>
`,
styles: [`
.container {
font-family: Arial, sans-serif;
text-align: center;
margin-top: 50px;
}
`]
})
export class AppComponent {
title = 'my-ngrx-app';
}
Exercises/Mini-Challenges
- Implement
upsertEntityfor a detailed edit:- Currently,
saveEditedTodousesaddOrUpdateTodowhich callsupsertEntity. Modify theTodoApiService.updateTodomethod to also handle the case where a todo with the given ID doesn’t exist (i.e., it creates a new one). This will fully showcase theupsertEntityfunctionality.
- Currently,
- Add a “Clear Completed” button:
- Add a new
rxMethodinTodosStorecalledclearCompletedTodos. This method should filter out all completed todos and update the state usingsetAllEntities. - Add a button in
TodoListComponentthat calls this method.
- Add a new
2.4. Custom Store Features (signalStoreFeature, withFeature, withLinkedState)
One of the most powerful aspects of NgRx Signal Store is its composability through custom store features. This allows you to create reusable pieces of state logic that can be easily added to any Signal Store. NgRx v20 enhances this with withFeature and withLinkedState.
Detailed Explanation
signalStoreFeature: A function that creates a reusable feature. It takes a list ofStoreFeatures(e.g.,withState,withMethods,withComputed,withEntities).withFeature(NgRx v20): Allows you to provide the current store instance to a custom feature, enabling generic features to access store-specific methods or properties. This is a game-changer for creating highly adaptable and reusable state management libraries.withLinkedState(NgRx v20): Enables the creation of derived, reactive state directly within the SignalStore. It allows you to define new state signals that are automatically computed whenever their source signals change, similar tocomputedbut specifically for adding new state properties.
Code Examples
Let’s create a generic withLoadingAndError feature that can be added to any store, and then use withFeature to apply it. We’ll also demonstrate withLinkedState.
1. Create a reusable withLoadingAndError feature:
// src/app/features/loading-error.feature.ts
import { signalStoreFeature, withState, withMethods, patchState } from '@ngrx/signals';
// Define the state for loading and error
interface LoadingErrorState {
isLoading: boolean;
error: string | null;
}
// Create a reusable feature
export function withLoadingAndError() {
return signalStoreFeature(
withState<LoadingErrorState>({ isLoading: false, error: null }),
withMethods((store) => ({
setLoading(isLoading: boolean) {
patchState(store, { isLoading });
},
setError(error: string | null) {
patchState(store, { error });
},
clearError() {
patchState(store, { error: null });
}
}))
);
}
2. Update TodosStore to use withLoadingAndError and withLinkedState:
// src/app/todos.store.ts (Updated)
import { computed, inject } from '@angular/core';
import { signalStore, withComputed, withMethods, withState, patchState, withLinkedState, withFeature } from '@ngrx/signals'; // Import withLinkedState, withFeature
import { withEntities, addEntity, removeEntity, updateEntity, setAllEntities, prependEntity, upsertEntity } from '@ngrx/signals/entities';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { pipe, switchMap, tap, delay, of } from 'rxjs';
import { tapResponse } from '@ngrx/operators';
import { Todo } from './models/todo.model';
import { withLoadingAndError } from './features/loading-error.feature'; // Import our new feature
// ... (TodoApiService remains the same)
interface TodosState {
filter: 'all' | 'active' | 'completed'; // isLoading and error are now from the feature
selectedTodoId: string | null; // Add a selected todo ID state
}
const initialState: TodosState = {
filter: 'all',
selectedTodoId: null,
};
export const TodosStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withEntities<Todo>(),
withLoadingAndError(), // Integrate our reusable feature
withComputed(({ entities, filter, selectedTodoId }) => ({
filteredTodos: computed(() => {
const allTodos = Object.values(entities());
switch (filter()) {
case 'active': return allTodos.filter(todo => !todo.completed);
case 'completed': return allTodos.filter(todo => todo.completed);
default: return allTodos;
}
}),
totalTodos: computed(() => Object.keys(entities()).length),
activeTodosCount: computed(() => Object.values(entities()).filter(todo => !todo.completed).length),
})),
// Use withLinkedState to derive a new state signal
withLinkedState(({ entities, selectedTodoId }) => ({
selectedTodo: computed(() => {
const id = selectedTodoId();
return id ? entities()[id] : null;
}),
})),
withMethods((store, todoApiService = inject(TodoApiService)) => ({
setFilter(filter: 'all' | 'active' | 'completed') {
patchState(store, { filter });
},
selectTodo(id: string | null) {
patchState(store, { selectedTodoId: id });
},
loadAllTodos: rxMethod<void>(
pipe(
tap(() => store.setLoading(true)), // Use method from withLoadingAndError
switchMap(() =>
todoApiService.getTodos().pipe(
tapResponse({
next: (todos) => patchState(store, setAllEntities(todos)),
error: (err: Error) => store.setError(err.message), // Use method from withLoadingAndError
finalize: () => store.setLoading(false), // Use method from withLoadingAndError
})
)
)
)
),
addTodo: rxMethod<string>(
pipe(
tap(() => store.setLoading(true)),
switchMap((title) =>
todoApiService.addTodo(title).pipe(
tapResponse({
next: (todo) => patchState(store, addEntity(todo)),
error: (err: Error) => store.setError(err.message),
finalize: () => store.setLoading(false),
})
)
)
)
),
updateTodoStatus: rxMethod<{ id: string; completed: boolean }>(
pipe(
tap(() => store.setLoading(true)),
switchMap(({ id, completed }) =>
todoApiService.updateTodo({ id, completed, title: '' }).pipe(
tapResponse({
next: (updatedTodo) => patchState(store, updateEntity({ id: updatedTodo.id, changes: { completed: updatedTodo.completed } })),
error: (err: Error) => store.setError(err.message),
finalize: () => store.setLoading(false),
})
)
)
)
),
deleteTodo: rxMethod<string>(
pipe(
tap(() => store.setLoading(true)),
switchMap((id) =>
todoApiService.deleteTodo(id).pipe(
tapResponse({
next: (deletedId) => patchState(store, removeEntity(deletedId)),
error: (err: Error) => store.setError(err.message),
finalize: () => store.setLoading(false),
})
)
)
)
),
addTodoToStart: rxMethod<string>(
pipe(
tap(() => store.setLoading(true)),
switchMap((title) =>
todoApiService.addTodo(title).pipe(
tapResponse({
next: (todo) => patchState(store, prependEntity(todo)),
error: (err: Error) => store.setError(err.message),
finalize: () => store.setLoading(false),
})
)
)
)
),
addOrUpdateTodo: rxMethod<Todo>(
pipe(
tap(() => store.setLoading(true)),
switchMap((todo) =>
todoApiService.updateTodo(todo).pipe(
tapResponse({
next: (res) => patchState(store, upsertEntity(res)),
error: (err: Error) => store.setError(err.message),
finalize: () => store.setLoading(false),
})
)
)
)
),
})),
);
Explanation:
withLoadingAndError(): This feature is simply added to thesignalStoredefinition. Its state and methods become available on theTodosStoreinstance.store.setLoading(true)/store.setError(err.message): We now use the methods provided bywithLoadingAndErrorinstead ofpatchStatedirectly for loading and error states. This makes ourTodosStoremethods cleaner and the loading/error logic reusable.withLinkedState(...): We added aselectedTodoIdto ourTodosState.withLinkedStatethen uses thisselectedTodoIdand theentitiesfromwithEntitiesto create a newselectedTodosignal that automatically holds the fullTodoobject when an ID is selected.
3. Update TodoListComponent to use the new loading/error pattern and selectedTodo:
// src/app/todo-list/todo-list.component.ts (Updated)
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule, NgFor, NgIf } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TodosStore } from '../todos.store';
import { Todo } from '../models/todo.model';
@Component({
selector: 'app-todo-list',
standalone: true,
imports: [CommonModule, FormsModule, NgFor, NgIf],
template: `
<div class="todo-container">
<h2>Todo List (NgRx Signal Store)</h2>
<div *ngIf="todosStore.isLoading()">Loading todos...</div>
<div *ngIf="todosStore.error()" style="color: red;">Error: {{ todosStore.error() }}</div>
<div class="filter-buttons">
<button (click)="todosStore.setFilter('all')" [class.active]="todosStore.filter() === 'all'">All ({{ todosStore.totalTodos() }})</button>
<button (click)="todosStore.setFilter('active')" [class.active]="todosStore.filter() === 'active'">Active ({{ todosStore.activeTodosCount() }})</button>
<button (click)="todosStore.setFilter('completed')" [class.active]="todosStore.filter() === 'completed'">Completed</button>
</div>
<div class="add-todo">
<input type="text" [(ngModel)]="newTodoTitle" placeholder="Add new todo">
<button (click)="addTodo()">Add Todo</button>
<button (click)="addTodoToStart()">Add to Start</button>
</div>
<ul class="todo-list">
<li *ngFor="let todo of todosStore.filteredTodos(); trackBy: trackById">
<input
type="checkbox"
[checked]="todo.completed"
(change)="toggleTodoStatus(todo)"
>
<span [class.completed]="todo.completed">{{ todo.title }}</span>
<button class="delete-button" (click)="deleteTodo(todo.id)">Delete</button>
<button class="edit-button" (click)="editTodo(todo)">Edit</button>
</li>
</ul>
<div *ngIf="todosStore.selectedTodo()">
<h3>Edit Todo: {{ todosStore.selectedTodo()?.title }}</h3>
<input type="text" [(ngModel)]="editedTodo!.title">
<button (click)="saveEditedTodo()">Save</button>
<button (click)="cancelEdit()">Cancel</button>
</div>
</div>
`,
styles: [`
/* ... (styles remain the same) ... */
`]
})
export class TodoListComponent implements OnInit {
readonly todosStore = inject(TodosStore);
newTodoTitle = '';
// isEditing is no longer needed, replaced by selectedTodo
editedTodo: Todo | null = null; // Still needed for the input binding
ngOnInit() {
this.todosStore.loadAllTodos();
}
addTodo() {
if (this.newTodoTitle.trim()) {
this.todosStore.addTodo(this.newTodoTitle.trim());
this.newTodoTitle = '';
}
}
addTodoToStart() {
if (this.newTodoTitle.trim()) {
this.todosStore.addTodoToStart(this.newTodoTitle.trim());
this.newTodoTitle = '';
}
}
toggleTodoStatus(todo: Todo) {
this.todosStore.updateTodoStatus({ id: todo.id, completed: !todo.completed });
}
deleteTodo(id: string) {
this.todosStore.deleteTodo(id);
}
editTodo(todo: Todo) {
this.todosStore.selectTodo(todo.id); // Select the todo
this.editedTodo = { ...todo }; // Copy for local input binding
}
saveEditedTodo() {
if (this.editedTodo && this.editedTodo.title.trim()) {
this.todosStore.addOrUpdateTodo(this.editedTodo);
this.todosStore.selectTodo(null); // Deselect the todo after saving
this.editedTodo = null;
}
}
cancelEdit() {
this.todosStore.selectTodo(null); // Deselect the todo
this.editedTodo = null;
}
trackById(index: number, todo: Todo): string {
return todo.id;
}
}
Exercises/Mini-Challenges
- Create a
withTimestampfeature:- Create a new
signalStoreFeaturecalledwithTimestampthat adds alastUpdatedstate property (aDateobject) and aupdateTimestampmethod that setslastUpdatedtonew Date(). - Add this
withTimestampfeature to yourTodosStore. - Call
store.updateTimestamp()inside thefinalizeblock ofloadAllTodos,addTodo,updateTodoStatus, anddeleteTodoinTodosStore. - Display the
lastUpdatedtimestamp in yourTodoListComponent.
- Create a new
- Explore
withFeature:- Imagine a
withLoggerfeature that takes the store name as an input. Can you create such a feature and integrate it? (Hint:withFeatureallows you to pass arguments to the feature factory).
- Imagine a
3. Intermediate Topics (Traditional NgRx Store)
While NgRx Signal Store is excellent for local and feature state, the traditional NgRx Store (@ngrx/store, @ngrx/effects, @ngrx/selectors) remains a powerful choice for managing complex, global application state. It provides a more structured and explicit approach, especially for larger applications that benefit from the Redux pattern.
3.1. Actions: Describing Events
Detailed Explanation
Actions are plain objects that describe unique events that happen in your application. They are the only way to initiate a state change. Actions must have a type property, which is a unique string identifier. They can also carry a payload—any data relevant to the event.
In modern NgRx, you create actions using createAction and props (for payload).
Code Examples
Let’s imagine a feature for managing users. We’ll define actions for loading users.
1. Create a file for user actions:
// src/app/users/user.actions.ts
import { createAction, props } from '@ngrx/store';
import { User } from '../models/user.model';
export const loadUsers = createAction(
'[User Page] Load Users'
);
export const loadUsersSuccess = createAction(
'[User API] Load Users Success',
props<{ users: User[] }>()
);
export const loadUsersFailure = createAction(
'[User API] Load Users Failure',
props<{ error: string }>()
);
export const addUser = createAction(
'[User Page] Add User',
props<{ user: Omit<User, 'id'> }>() // User without ID, ID generated by API
);
export const addUserSuccess = createAction(
'[User API] Add User Success',
props<{ user: User }>()
);
export const addUserFailure = createAction(
'[User API] Add User Failure',
props<{ error: string }>()
);
Explanation:
createAction('[User Page] Load Users'): Defines an action with a unique type string.props<{ users: User[] }>(): Specifies thatloadUsersSuccessaction will carry ausersarray of typeUser[].
Exercises/Mini-Challenges
- Define more actions:
- Add actions for
updateUser(withidandchanges),updateUserSuccess,updateUserFailure. - Add actions for
deleteUser(withid),deleteUserSuccess,deleteUserFailure. - Consider actions for selecting a user (e.g.,
selectUser,clearSelectedUser).
- Add actions for
3.2. Reducers: Handling State Changes
Detailed Explanation
Reducers are pure functions that take the current state and an action, and return a new state. They are the only way to change the application state in the traditional NgRx Store. Reducers must be pure: given the same state and action, they must always return the same new state, and they must not mutate the original state directly. Instead, they should return a new state object.
You define reducers using createReducer and on functions.
Code Examples
Let’s create a reducer for our user state.
1. Define the User interface:
// src/app/models/user.model.ts
export interface User {
id: string;
name: string;
email: string;
}
2. Create a file for the user reducer:
// src/app/users/user.reducer.ts
import { createReducer, on } from '@ngrx/store';
import * as UserActions from './user.actions';
import { User } from '../models/user.model';
export interface UserState {
users: User[];
isLoading: boolean;
error: string | null;
selectedUserId: string | null;
}
export const initialUserState: UserState = {
users: [],
isLoading: false,
error: null,
selectedUserId: null,
};
export const userReducer = createReducer(
initialUserState,
on(UserActions.loadUsers, (state) => ({ ...state, isLoading: true, error: null })),
on(UserActions.loadUsersSuccess, (state, { users }) => ({
...state,
users,
isLoading: false,
error: null,
})),
on(UserActions.loadUsersFailure, (state, { error }) => ({
...state,
isLoading: false,
error,
})),
on(UserActions.addUserSuccess, (state, { user }) => ({
...state,
users: [...state.users, user],
isLoading: false,
error: null,
})),
on(UserActions.addUserFailure, (state, { error }) => ({
...state,
isLoading: false,
error,
})),
);
Explanation:
UserState: Defines the shape of the slice of state managed by this reducer.initialUserState: The initial value for this state slice.createReducer(initialUserState, ...): Initializes the reducer with the initial state.on(UserActions.loadUsers, (state) => ({ ...state, isLoading: true, error: null })): WhenloadUsersaction is dispatched, it returns a new state object withisLoadingset totrueanderrorcleared. Crucially, we use the spread operator (...state) to create a new state object, never mutating the original.on(UserActions.loadUsersSuccess, (state, { users }) => ({ ...state, users, isLoading: false, error: null })): On success, update theusersarray.
Exercises/Mini-Challenges
- Complete the reducer for other actions:
- Add
onhandlers forupdateUserSuccess,updateUserFailure,deleteUserSuccess,deleteUserFailure. - For
updateUserSuccess, you’ll need to find the user by ID and update its properties in an immutable way. - For
deleteUserSuccess, filter out the deleted user from theusersarray. - Add an
onhandler forselectUserto updateselectedUserIdandclearSelectedUserto set it tonull.
- Add
3.3. Store: The Central Repository
Detailed Explanation
The Store is an observable of state and an observer of actions. It’s the central place where your entire application state resides. Components and services interact with the store by dispatching actions and selecting parts of the state.
To set up the store, you’ll use StoreModule.forRoot in your app.config.ts (for standalone applications) or AppModule (for module-based applications).
Code Examples
1. Register the reducer in your Angular application:
// src/app/app.config.ts
import { ApplicationConfig, isDevMode } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideStore } from '@ngrx/store';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { routes } from './app.routes';
import { userReducer } from './users/user.reducer'; // Import your reducer
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideStore({ user: userReducer }), // Register your reducer here
provideStoreDevtools({ maxAge: 25, logOnly: !isDevMode() }), // Optional: for Redux DevTools
]
};
Explanation:
provideStore({ user: userReducer }): This function registers theuserReducerunder the keyuserin your global state. Now, your application’s state will have auserproperty managed by this reducer.provideStoreDevtools(...): This is an optional but highly recommended step. It integrates the Redux DevTools Extension (if installed in your browser), allowing you to inspect state changes, actions, and even time-travel debug your application.
2. Dispatching Actions and Selecting State in a Component:
// src/app/users/user-list/user-list.component.ts
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule, NgFor, NgIf } from '@angular/common';
import { Store } from '@ngrx/store';
import * as UserActions from '../user.actions';
import { User } from '../../models/user.model';
import { selectUserError, selectUserIsLoading, selectUsers } from '../user.selectors'; // Import selectors
import { Observable } from 'rxjs';
@Component({
selector: 'app-user-list',
standalone: true,
imports: [CommonModule, NgFor, NgIf],
template: `
<div class="user-container">
<h2>User List (NgRx Traditional Store)</h2>
<div *ngIf="isLoading$ | async">Loading users...</div>
<div *ngIf="error$ | async as error" style="color: red;">Error: {{ error }}</div>
<button (click)="loadUsers()">Load Users</button>
<ul class="user-list" *ngIf="(users$ | async) as users">
<li *ngFor="let user of users; trackBy: trackById">
{{ user.name }} ({{ user.email }})
</li>
</ul>
</div>
`,
styles: [`
.user-container {
max-width: 600px;
margin: 50px auto;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
font-family: Arial, sans-serif;
}
.user-list { list-style: none; padding: 0; }
.user-list li { padding: 8px 0; border-bottom: 1px solid #eee; }
button { padding: 10px 20px; margin-top: 10px; cursor: pointer; }
`]
})
export class UserListComponent implements OnInit {
private store = inject(Store);
users$: Observable<User[]> = this.store.select(selectUsers);
isLoading$: Observable<boolean> = this.store.select(selectUserIsLoading);
error$: Observable<string | null> = this.store.select(selectUserError);
ngOnInit() {
// Optionally load users on component initialization
// this.loadUsers();
}
loadUsers() {
this.store.dispatch(UserActions.loadUsers());
}
trackById(index: number, user: User): string {
return user.id;
}
}
3. Include UserListComponent in app.component.ts:
// src/app/app.component.ts
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import { CounterStore } from './counter.store';
import { TodoListComponent } from './todo-list/todo-list.component';
import { UserListComponent } from './users/user-list/user-list.component'; // Import UserListComponent
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, RouterOutlet, TodoListComponent, UserListComponent], // Add UserListComponent
template: `
<div class="container">
<app-todo-list></app-todo-list>
<hr>
<app-user-list></app-user-list>
</div>
`,
styles: [`
.container {
font-family: Arial, sans-serif;
text-align: center;
margin-top: 50px;
}
hr {
margin: 40px auto;
width: 80%;
border-top: 1px solid #eee;
}
`]
})
export class AppComponent {
title = 'my-ngrx-app';
}
Exercises/Mini-Challenges
- Add
addUserfunctionality:- In
UserListComponent, add an input field and a button to allow adding new users (just name and email). - Dispatch the
UserActions.addUseraction with the new user data. - Remember that the
addUserSuccessaction will be handled by the reducer to actually add the user to the state.
- In
- Integrate Redux DevTools:
- Install the Redux DevTools Extension in your browser (Chrome/Firefox).
- Open your browser’s developer tools and look for the Redux tab. Observe the actions being dispatched and the state changes as you interact with the User List.
3.4. Selectors: Querying State
Detailed Explanation
Selectors are pure functions used to select, derive, and compose pieces of state. They provide a powerful way to query your store state, ensuring that components only re-render when the specific data they care about changes. Selectors are memoized, meaning they only re-compute their values if their input state has changed, which is crucial for performance.
You create selectors using createSelector and createFeatureSelector.
Code Examples
Let’s create selectors for our user state.
1. Create a file for user selectors:
// src/app/users/user.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { UserState } from './user.reducer';
// Select the feature slice of state (the 'user' part in app.config.ts)
export const selectUserState = createFeatureSelector<UserState>('user');
// Select a specific property from the user state
export const selectUsers = createSelector(
selectUserState,
(state: UserState) => state.users
);
export const selectUserIsLoading = createSelector(
selectUserState,
(state: UserState) => state.isLoading
);
export const selectUserError = createSelector(
selectUserState,
(state: UserState) => state.error
);
// Derived selector: select the count of users
export const selectUserCount = createSelector(
selectUsers,
(users) => users.length
);
// Derived selector: select a user by ID
export const selectSelectedUserId = createSelector(
selectUserState,
(state: UserState) => state.selectedUserId
);
export const selectSelectedUser = createSelector(
selectUsers,
selectSelectedUserId,
(users, selectedId) => users.find(user => user.id === selectedId) || null
);
Explanation:
createFeatureSelector<UserState>('user'): This creates a selector that returns the entireuserslice of your global state.createSelector(selectUserState, (state: UserState) => state.users): This selector takesselectUserStateas an input and projects (extracts) theusersarray from it.createSelector(selectUsers, (users) => users.length): This is a composed selector. It takes theselectUsersselector’s output and calculates the length. This is a powerful pattern for deriving new data from existing state.
Exercises/Mini-Challenges
- Create more derived selectors:
- Create a selector
selectActiveUsersthat filters out users based on some criteria (e.g., if you add anisActiveproperty toUser). - Create a selector
selectUserNamesthat returns an array of only user names.
- Create a selector
- Use
selectSelectedUser:- In your
UserListComponent, add an “Edit” button next to each user. When clicked, it should dispatch aselectUseraction (you need to define this action and handle it in the reducer to updateselectedUserId). - Then, display the
selectedUser$(from your new selector) in an editable form below the list. - Add a “Save” button to dispatch
updateUserand a “Cancel” button to dispatchclearSelectedUser.
- In your
3.5. Effects: Handling Side Effects (Asynchronous Operations)
Detailed Explanation
Effects are where you handle side effects—any operation that interacts with the outside world, like fetching data from an API, interacting with local storage, or performing routing. Effects listen for dispatched actions and then perform asynchronous operations, dispatching new actions (success or failure) based on the result.
Effects use RxJS observables to manage these asynchronous operations. You define effects using the createEffect function.
Code Examples
Let’s create an effect to handle our loadUsers action.
1. Create a User service for API calls:
// src/app/users/user.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, throwError } from 'rxjs';
import { delay } from 'rxjs/operators';
import { User } from '../models/user.model';
@Injectable({ providedIn: 'root' })
export class UserService {
private users: User[] = [
{ id: 'u1', name: 'Alice', email: 'alice@example.com' },
{ id: 'u2', name: 'Bob', email: 'bob@example.com' },
{ id: 'u3', name: 'Charlie', email: 'charlie@example.com' },
];
private nextId = 4; // for adding new users
constructor(private http: HttpClient) {} // HttpClient not strictly needed for mock, but good practice
getUsers(): Observable<User[]> {
console.log('User API: Fetching users...');
// Simulate an API call
return of(this.users).pipe(delay(1000));
}
addUser(user: Omit<User, 'id'>): Observable<User> {
console.log('User API: Adding user...', user.name);
const newUser: User = { ...user, id: `u${this.nextId++}` };
this.users.push(newUser);
return of(newUser).pipe(delay(500));
}
updateUser(id: string, changes: Partial<User>): Observable<User> {
console.log('User API: Updating user...', id, changes);
const userIndex = this.users.findIndex(u => u.id === id);
if (userIndex > -1) {
this.users[userIndex] = { ...this.users[userIndex], ...changes };
return of(this.users[userIndex]).pipe(delay(500));
}
return throwError(() => new Error(`User with ID ${id} not found`)).pipe(delay(500));
}
deleteUser(id: string): Observable<string> {
console.log('User API: Deleting user...', id);
const initialLength = this.users.length;
this.users = this.users.filter(u => u.id !== id);
if (this.users.length < initialLength) {
return of(id).pipe(delay(500));
}
return throwError(() => new Error(`User with ID ${id} not found`)).pipe(delay(500));
}
}
2. Create a file for user effects:
// src/app/users/user.effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, map, concatMap, switchMap, tap } from 'rxjs/operators';
import { of } from 'rxjs';
import * as UserActions from './user.actions';
import { UserService } from './user.service';
@Injectable()
export class UserEffects {
constructor(private actions$: Actions, private userService: UserService) {}
loadUsers$ = createEffect(() =>
this.actions$.pipe(
ofType(UserActions.loadUsers), // Listen for loadUsers action
switchMap(() => // Use switchMap to cancel previous requests if a new one comes in
this.userService.getUsers().pipe(
map(users => UserActions.loadUsersSuccess({ users })), // On success, dispatch success action
catchError(error => of(UserActions.loadUsersFailure({ error: error.message }))) // On error, dispatch failure action
)
)
)
);
addUser$ = createEffect(() =>
this.actions$.pipe(
ofType(UserActions.addUser),
concatMap(action =>
this.userService.addUser(action.user).pipe(
map(user => UserActions.addUserSuccess({ user })),
catchError(error => of(UserActions.addUserFailure({ error: error.message })))
)
)
)
);
updateUser$ = createEffect(() =>
this.actions$.pipe(
ofType(UserActions.updateUser),
concatMap(action =>
this.userService.updateUser(action.id, action.changes).pipe(
map(user => UserActions.updateUserSuccess({ user })),
catchError(error => of(UserActions.updateUserFailure({ error: error.message })))
)
)
)
);
deleteUser$ = createEffect(() =>
this.actions$.pipe(
ofType(UserActions.deleteUser),
concatMap(action =>
this.userService.deleteUser(action.id).pipe(
map(id => UserActions.deleteUserSuccess({ id })),
catchError(error => of(UserActions.deleteUserFailure({ error: error.message })))
)
)
)
);
// Optional: A non-dispatching effect (does not return an action)
logUsersLoaded$ = createEffect(() =>
this.actions$.pipe(
ofType(UserActions.loadUsersSuccess),
tap(({ users }) => console.log('Users loaded successfully:', users.length)),
),
{ dispatch: false } // Important: tell NgRx this effect does not dispatch an action
);
}
Explanation:
@Injectable(): Effects are services, so they need this decorator.Actions: This is an injectable RxJS Observable of all actions dispatched in the application.createEffect(() => ...): Defines an effect.this.actions$.pipe(...): Start with the stream of all actions.ofType(UserActions.loadUsers): Filters the action stream to only listen forloadUsersactions.switchMap(() => this.userService.getUsers().pipe(...)): WhenloadUsersis dispatched, it callsuserService.getUsers().switchMapis used here because ifloadUsersis dispatched multiple times before the previous API call completes,switchMapwill cancel the ongoing call and switch to the new one. This is good for “typeahead” search scenarios. For other scenarios (like adding items),concatMap(ensures order) ormergeMap(runs in parallel) might be more appropriate.
map(users => UserActions.loadUsersSuccess({ users })): On successful response from theUserService, map the data to aloadUsersSuccessaction and dispatch it.catchError(error => of(UserActions.loadUsersFailure({ error: error.message }))): If theUserServiceobservable errors, catch it and dispatch aloadUsersFailureaction.{ dispatch: false }: For effects that only perform a side effect (like logging) and don’t dispatch any new actions, you must add{ dispatch: false }.
3. Register the effects in app.config.ts:
// src/app/app.config.ts
import { ApplicationConfig, isDevMode } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideStore } from '@ngrx/store';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { provideEffects } from '@ngrx/effects'; // Import provideEffects
import { routes } from './app.routes';
import { userReducer } from './users/user.reducer';
import { UserEffects } from './users/user.effects'; // Import your effects
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideStore({ user: userReducer }),
provideEffects([UserEffects]), // Register your effects here
provideStoreDevtools({ maxAge: 25, logOnly: !isDevMode() }),
]
};
4. Update UserListComponent to include update/delete actions:
First, remember to update user.actions.ts and user.reducer.ts with updateUser, updateUserSuccess, updateUserFailure, deleteUser, deleteUserSuccess, deleteUserFailure actions and their respective reducer logic.
// src/app/users/user-list/user-list.component.ts (Updated)
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule, NgFor, NgIf } from '@angular/common';
import { FormsModule } from '@angular/forms'; // For input binding
import { Store } from '@ngrx/store';
import * as UserActions from '../user.actions';
import { User } from '../../models/user.model';
import { selectUserError, selectUserIsLoading, selectUsers, selectSelectedUser, selectSelectedUserId } from '../user.selectors';
import { Observable } from 'rxjs';
@Component({
selector: 'app-user-list',
standalone: true,
imports: [CommonModule, NgFor, NgIf, FormsModule], // Add FormsModule
template: `
<div class="user-container">
<h2>User List (NgRx Traditional Store)</h2>
<div *ngIf="isLoading$ | async">Loading users...</div>
<div *ngIf="error$ | async as error" style="color: red;">Error: {{ error }}</div>
<div class="actions">
<button (click)="loadUsers()">Load Users</button>
<div class="add-user">
<input type="text" [(ngModel)]="newUserName" placeholder="New user name">
<input type="email" [(ngModel)]="newUserEmail" placeholder="New user email">
<button (click)="addUser()">Add User</button>
</div>
</div>
<ul class="user-list" *ngIf="(users$ | async) as users">
<li *ngFor="let user of users; trackBy: trackById">
{{ user.name }} ({{ user.email }})
<button class="edit-button" (click)="editUser(user)">Edit</button>
<button class="delete-button" (click)="deleteUser(user.id)">Delete</button>
</li>
</ul>
<div *ngIf="selectedUser$ | async as selectedUser" class="edit-form">
<h3>Edit User: {{ selectedUser.name }}</h3>
<input type="text" [(ngModel)]="editedUserName" placeholder="Edit name">
<input type="email" [(ngModel)]="editedUserEmail" placeholder="Edit email">
<button (click)="saveEditedUser(selectedUser.id)">Save</button>
<button (click)="cancelEdit()">Cancel</button>
</div>
</div>
`,
styles: [`
.user-container {
max-width: 600px;
margin: 50px auto;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
font-family: Arial, sans-serif;
}
.user-list { list-style: none; padding: 0; }
.user-list li { padding: 8px 0; border-bottom: 1px solid #eee; display: flex; align-items: center; }
.user-list li:last-child { border-bottom: none; }
.user-list li span { flex-grow: 1; }
button { padding: 8px 15px; margin: 5px; cursor: pointer; border-radius: 4px; border: 1px solid #ccc; background-color: #f0f0f0; }
.actions { margin-top: 20px; display: flex; flex-direction: column; gap: 10px; }
.add-user { display: flex; gap: 10px; margin-top: 10px; }
.add-user input { flex-grow: 1; padding: 8px; border-radius: 4px; border: 1px solid #ccc; }
.add-user button { background-color: #28a745; color: white; border: none; }
.edit-button { background-color: #ffc107; color: black; border-color: #ffc107; }
.delete-button { background-color: #dc3545; color: white; border-color: #dc3545; }
.edit-form { margin-top: 20px; padding: 15px; border: 1px solid #007bff; border-radius: 8px; }
.edit-form input { display: block; width: calc(100% - 16px); padding: 8px; margin-bottom: 10px; border-radius: 4px; border: 1px solid #ccc; }
.edit-form button { background-color: #007bff; color: white; border: none; }
.edit-form button:last-child { background-color: #6c757d; }
`]
})
export class UserListComponent implements OnInit {
private store = inject(Store);
users$: Observable<User[]> = this.store.select(selectUsers);
isLoading$: Observable<boolean> = this.store.select(selectUserIsLoading);
error$: Observable<string | null> = this.store.select(selectUserError);
selectedUser$: Observable<User | null> = this.store.select(selectSelectedUser);
selectedUserId$: Observable<string | null> = this.store.select(selectSelectedUserId); // To pre-fill edit form
newUserName = '';
newUserEmail = '';
editedUserName = '';
editedUserEmail = '';
constructor() {
this.selectedUser$.subscribe(user => {
if (user) {
this.editedUserName = user.name;
this.editedUserEmail = user.email;
} else {
this.editedUserName = '';
this.editedUserEmail = '';
}
});
}
ngOnInit() {
this.loadUsers(); // Load users on component initialization
}
loadUsers() {
this.store.dispatch(UserActions.loadUsers());
}
addUser() {
if (this.newUserName.trim() && this.newUserEmail.trim()) {
this.store.dispatch(UserActions.addUser({ user: { name: this.newUserName.trim(), email: this.newUserEmail.trim() } }));
this.newUserName = '';
this.newUserEmail = '';
}
}
editUser(user: User) {
this.store.dispatch(UserActions.selectUser({ id: user.id }));
}
saveEditedUser(id: string) {
if (this.editedUserName.trim() && this.editedUserEmail.trim()) {
this.store.dispatch(UserActions.updateUser({
id,
changes: { name: this.editedUserName.trim(), email: this.editedUserEmail.trim() }
}));
this.store.dispatch(UserActions.clearSelectedUser()); // Clear selection after save
}
}
cancelEdit() {
this.store.dispatch(UserActions.clearSelectedUser());
}
deleteUser(id: string) {
this.store.dispatch(UserActions.deleteUser({ id }));
}
trackById(index: number, user: User): string {
return user.id;
}
}
Exercises/Mini-Challenges
- Refactor with
concatMapvsswitchMap:- Currently
addUser$usesconcatMap. Change it toswitchMapand observe the behavior if you click the “Add User” button multiple times quickly. Which one is more appropriate for adding new items? Why? (Hint: consider order and cancellation). - Change
loadUsers$toconcatMapand consider if it’s suitable for loading data.
- Currently
- Add a global notification effect:
- Create a new effect that listens for all
*Successactions (e.g.,loadUsersSuccess,addUserSuccess) and dispatches a genericNotificationActions.showSuccessNotificationwith a message. - Similarly, for
*Failureactions, dispatchNotificationActions.showErrorNotification. - (This would involve creating a new
NotificationModulewith its own actions, reducer, and a component to display notifications).
- Create a new effect that listens for all
4. Advanced Topics and Best Practices
Now that you have a solid understanding of both NgRx Signal Store and the traditional NgRx Store, let’s explore some more advanced concepts, best practices, and how to choose between the two.
4.1. NgRx Signal Store vs. Traditional NgRx Store
NgRx v20 with Signal Store marks a significant evolution. It’s crucial to understand when to use which approach.
NgRx Signal Store (with
@ngrx/signals):- Best for: Local component state, feature state, and scenarios where you want a more lightweight, service-like state management experience that leverages Angular’s Signals API.
- Benefits:
- Less boilerplate compared to traditional NgRx (no separate actions, reducers, effects files for simple cases).
- Integrates seamlessly with Angular Signals, offering fine-grained reactivity and potential performance benefits in a zoneless environment.
- Excellent for building reusable features (
signalStoreFeature). rxMethodsimplifies async operations within the store.
- Considerations: For very complex global state with intricate cross-cutting concerns, extensive logging, or time-travel debugging requirements, the explicit nature of traditional NgRx might still be preferred. The new experimental Events plugin for SignalStore helps bridge this gap, offering Redux-style eventing in a SignalStore context.
Traditional NgRx Store (with
@ngrx/store,@ngrx/effects):- Best for: Global application state, large-scale enterprise applications, and situations where you need strict adherence to the Redux pattern, explicit action/reducer/effect separation, and robust debugging capabilities.
- Benefits:
- Unidirectional data flow is very explicit and enforced.
- Powerful for complex side effects and inter-module communication.
- Mature ecosystem with powerful tools like Redux DevTools for time-travel debugging.
- Clear separation of concerns (actions describe what happened, reducers define how state changes, effects handle side effects).
- Considerations: Can introduce more boilerplate, especially for simple state management needs. Requires a deeper understanding of RxJS.
Recommendation:
For new features and simpler state management, start with NgRx Signal Store. Its integration with Angular Signals makes it a natural fit for modern Angular applications. If you encounter highly complex, global state management challenges that require explicit Redux-like patterns (e.g., many interrelated effects, complex middleware, deep analytics), consider the traditional NgRx Store. Many applications can effectively use a hybrid approach, leveraging Signal Stores for local/feature state and the traditional Store for global concerns.
4.2. Immutable Data Structures
Both NgRx Signal Store and traditional NgRx Store rely heavily on immutability. This means that when you update state, you should never directly modify the existing state object or array. Instead, you create new objects or arrays with the desired changes.
Why Immutability?
- Predictability: It ensures that state changes are traceable and prevents unexpected side effects.
- Performance: Angular’s change detection (especially with
OnPushstrategy and Signals) and NgRx’s memoized selectors rely on reference checking. If you mutate an object, its reference doesn’t change, and components/selectors might not detect the update, leading to stale UI. - Debuggability: Immutability simplifies debugging as you can easily compare previous and current states to see what changed.
Techniques for Immutability:
- Spread Operator (
...): The most common way to create new objects and arrays.// Object const newState = { ...oldState, propertyToChange: newValue }; // Array const newArray = [...oldArray, newItem]; // Add item const newArrayModified = oldArray.map(item => item.id === id ? { ...item, changedProp: newProp } : item); // Update item const newArrayFiltered = oldArray.filter(item => item.id !== id); // Remove item patchStatein Signal Store: As seen,patchStateinternally handles immutable updates for you.addEntity,updateEntity,removeEntityfrom@ngrx/signals/entities: These helpers also ensure immutable operations on entity collections.
4.3. Error Handling Strategies
Robust error handling is critical in any application.
- In NgRx Signal Store (with
rxMethod):- Use
tapResponse(from@ngrx/operators) withinrxMethod’sswitchMap/concatMapto handlenext,error, andfinalizelogic. - Store the error message in your store’s state (
error: string | null) and display it in the UI.
- Use
- In Traditional NgRx Store (with Effects):
- Always use
catchErrorwithin your effects’ observable pipes. - On error, dispatch a specific
Failureaction with the error message in its payload. - Your reducers should then update the state to reflect the error, which can then be selected and displayed in the UI.
- Consider a global error handling effect that listens to all
*Failureactions and displays a toast notification or logs the error to an analytics service.
- Always use
4.4. Testing NgRx Components
Testing is a cornerstone of maintainable applications.
- Testing NgRx Signal Store:
- Signal Stores are essentially services. You can unit test them by injecting them and calling their methods, then asserting on the values of their signals. Mock any injected dependencies (like
ApiService). - Example:
import { TestBed } from '@angular/core/testing'; import { CounterStore } from './counter.store'; import { signal } from '@angular/core'; describe('CounterStore', () => { let store: CounterStore; beforeEach(() => { TestBed.configureTestingModule({ providers: [CounterStore], // Provide the store }); store = TestBed.inject(CounterStore); }); it('should increment the count', () => { expect(store.count()).toBe(0); store.increment(); expect(store.count()).toBe(1); }); it('should double the count correctly', () => { store.increment(); // count becomes 1 expect(store.doubleCount()).toBe(2); store.incrementBy(4); // count becomes 5 expect(store.doubleCount()).toBe(10); }); });
- Signal Stores are essentially services. You can unit test them by injecting them and calling their methods, then asserting on the values of their signals. Mock any injected dependencies (like
- Testing Traditional NgRx Store:
- Actions: Simple plain objects, easily asserted.
- Reducers: Pure functions. Test them by calling the reducer with an initial state and an action, then assert that the new state is as expected.
- Selectors: Pure functions. Test them by calling the selector with a mock state, then assert the output.
- Effects: These are the most complex to test. You typically use a
TestSchedulerfromrxjs/testingorprovideMockActionsfrom@ngrx/effects/testingto mock theActionsstream and assert on dispatched actions.
4.5. Lazy Loading NgRx Modules
For larger applications, you should lazy load your NgRx state and effects along with your Angular feature modules. This improves initial load performance by only loading the necessary code when a user navigates to a specific feature.
For Traditional NgRx Store:
- Use
StoreModule.forFeature('featureName', featureReducer)in your lazy-loaded feature module. - Use
EffectsModule.forFeature([FeatureEffects])in your lazy-loaded feature module.
- Use
For NgRx Signal Store:
- Signal Stores are essentially injectable services. If a Signal Store is
providedIn: 'root', it’s eagerly loaded. - To lazy load, provide the Signal Store at the component or feature module level. When the component/module is loaded, the store becomes available.
// In a lazy-loaded component: @Component({ selector: 'app-lazy-feature', standalone: true, imports: [CommonModule], providers: [FeatureStore], // The FeatureStore will be provided when this component loads template: `...` }) export class LazyFeatureComponent { readonly featureStore = inject(FeatureStore); }- Signal Stores are essentially injectable services. If a Signal Store is
4.6. Other NgRx Packages
NgRx offers a suite of libraries for different needs:
@ngrx/router-store: Synchronizes Angular Router state with the NgRx Store. Useful for time-travel debugging router navigations.@ngrx/data: A powerful abstraction layer over@ngrx/storeand@ngrx/effectsthat automates most of the boilerplate for managing entity collections. If you have many similar entity CRUD operations,ngrx/datacan save you a lot of time. However, with@ngrx/signals/entities, the Signal Store offers a compelling, lightweight alternative for entity management.@ngrx/component-store: Previously the go-to for local, component-level state. Now, with@ngrx/signals, the NgRx Signal Store effectively replaces@ngrx/component-storeas the recommended approach for this use case due to its tighter integration with Angular’s reactivity model.@ngrx/schematics: CLI tools to generate NgRx actions, reducers, effects, etc., reducing boilerplate.@ngrx/eslint-plugin: ESLint rules to enforce best practices for NgRx.
5. Guided Projects
Let’s apply our knowledge to build a more complex application. We’ll build a simplified Product Catalog application.
Project 1: Product Catalog with NgRx Signal Store
Objective: Create a simple product catalog where users can view, add, update, and delete products. We will manage product state using NgRx Signal Store.
Features:
- Display a list of products.
- Add a new product.
- Edit an existing product.
- Delete a product.
- Show loading and error states.
Prerequisites:
- An Angular v20 project (created in section 1.4).
- NgRx Signal Store dependencies (
@ngrx/signals,@ngrx/signals/entities,@ngrx/operators,@ngrx/signals/rxjs-interop).
Step-by-Step Guide:
Step 5.1. Define the Product Model
Create src/app/models/product.model.ts:
// src/app/models/product.model.ts
export interface Product {
id: string;
name: string;
price: number;
description: string;
}
Step 5.2. Create a Mock Product API Service
Create src/app/products/product.service.ts:
// src/app/products/product.service.ts
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' },
{ id: 'p2', name: 'Mouse', price: 25, description: 'Wireless optical mouse' },
{ id: 'p3', name: 'Keyboard', price: 75, description: 'Mechanical keyboard' },
];
private nextId = 4;
getProducts(): Observable<Product[]> {
console.log('Product API: Fetching products...');
return of([...this.products]).pipe(delay(700));
}
addProduct(product: Omit<Product, 'id'>): Observable<Product> {
console.log('Product API: Adding product...', product.name);
const newProduct: Product = { ...product, id: `p${this.nextId++}` };
this.products.push(newProduct);
return of(newProduct).pipe(delay(500));
}
updateProduct(id: string, changes: Partial<Product>): Observable<Product> {
console.log('Product API: Updating product...', id, changes);
const productIndex = this.products.findIndex(p => p.id === id);
if (productIndex > -1) {
this.products[productIndex] = { ...this.products[productIndex], ...changes };
return of(this.products[productIndex]).pipe(delay(500));
}
return throwError(() => new Error(`Product with ID ${id} not found`)).pipe(delay(500));
}
deleteProduct(id: string): Observable<string> {
console.log('Product API: Deleting product...', id);
const initialLength = this.products.length;
this.products = this.products.filter(p => p.id !== id);
if (this.products.length < initialLength) {
return of(id).pipe(delay(500));
}
return throwError(() => new Error(`Product with ID ${id} not found`)).pipe(delay(500));
}
}
Step 5.3. Create the Product Signal Store
Create src/app/products/product.store.ts:
// src/app/products/product.store.ts
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 } 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'; // Reusing our feature
interface ProductsState {
selectedProductId: string | null;
searchTerm: string;
}
const initialState: ProductsState = {
selectedProductId: null,
searchTerm: '',
};
export const ProductStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withEntities<Product>(),
withLoadingAndError(), // Integrate the reusable loading/error feature
withComputed(({ entities, selectedProductId, searchTerm }) => ({
allProducts: computed(() => Object.values(entities())),
filteredProducts: computed(() => {
const products = Object.values(entities());
const term = searchTerm().toLowerCase();
return products.filter(product =>
product.name.toLowerCase().includes(term) ||
product.description.toLowerCase().includes(term)
);
}),
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 });
},
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)),
switchMap((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)),
switchMap(({ 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)),
switchMap((id) =>
productService.deleteProduct(id).pipe(
tapResponse({
next: (deletedId) => patchState(store, removeEntity(deletedId)),
error: (err: Error) => store.setError(err.message),
finalize: () => store.setLoading(false),
})
)
)
)
),
})),
);
Step 5.4. Create the Product Catalog Component
Create src/app/products/product-catalog/product-catalog.component.ts:
// src/app/products/product-catalog/product-catalog.component.ts
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule, NgFor, NgIf } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ProductStore } from '../product.store';
import { Product } from '../../models/product.model';
@Component({
selector: 'app-product-catalog',
standalone: true,
imports: [CommonModule, FormsModule, NgFor, NgIf],
template: `
<div class="product-catalog-container">
<h2>Product Catalog (NgRx Signal Store)</h2>
<div *ngIf="productStore.isLoading()">Loading products...</div>
<div *ngIf="productStore.error()" style="color: red;">Error: {{ productStore.error() }}</div>
<div class="controls">
<input type="text" [(ngModel)]="searchTerm" (input)="onSearchChange()" placeholder="Search products">
<button (click)="productStore.loadProducts()">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 productStore.filteredProducts(); 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.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>
<button (click)="saveEditedProduct()">Save Changes</button>
<button (click)="cancelEdit()">Cancel</button>
</div>
</div>
`,
styles: [`
.product-catalog-container {
max-width: 800px;
margin: 50px auto;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
font-family: Arial, sans-serif;
}
.controls { display: flex; gap: 10px; margin-bottom: 20px; }
.controls input { flex-grow: 1; padding: 8px; border-radius: 4px; border: 1px solid #ccc; }
.controls button { padding: 8px 15px; background-color: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; }
.add-product-form, .edit-product-form {
border: 1px solid #eee;
padding: 15px;
margin-bottom: 20px;
border-radius: 8px;
}
.add-product-form input, .add-product-form textarea,
.edit-product-form input, .edit-product-form textarea {
display: block;
width: calc(100% - 16px);
padding: 8px;
margin-bottom: 10px;
border-radius: 4px;
border: 1px solid #ccc;
}
.add-product-form button, .edit-product-form button {
padding: 8px 15px;
margin-right: 10px;
background-color: #28a745;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.edit-product-form button:last-child { background-color: #6c757d; }
.product-list { list-style: none; padding: 0; }
.product-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 0;
border-bottom: 1px solid #eee;
}
.product-list li:last-child { border-bottom: none; }
.product-list li strong { font-size: 1.1em; }
.product-list li p { margin: 5px 0 0; color: #666; font-size: 0.9em; }
.product-list li .actions button {
padding: 5px 10px;
margin-left: 5px;
background-color: #ffc107;
color: black;
border: none;
border-radius: 4px;
cursor: pointer;
}
.product-list li .actions button:last-child { background-color: #dc3545; color: white; }
`]
})
export class ProductCatalogComponent implements OnInit {
readonly productStore = inject(ProductStore);
searchTerm: string = '';
newProductName: string = '';
newProductPrice: number = 0;
newProductDescription: string = '';
editedProduct: Product = { id: '', name: '', price: 0, description: '' }; // Local state for editing
constructor() {
// Subscribe to selectedProduct to populate the edit form
this.productStore.selectedProduct().subscribe(product => {
if (product) {
this.editedProduct = { ...product }; // Copy to avoid direct mutation of store state
}
});
}
ngOnInit(): void {
this.productStore.loadProducts();
}
onSearchChange(): void {
this.productStore.setSearchTerm(this.searchTerm);
}
addNewProduct(): void {
if (this.newProductName && this.newProductPrice > 0 && this.newProductDescription) {
this.productStore.addProduct({
name: this.newProductName,
price: this.newProductPrice,
description: this.newProductDescription,
});
this.clearNewProductForm();
}
}
editProduct(product: Product): void {
this.productStore.selectProduct(product.id);
}
saveEditedProduct(): void {
if (this.editedProduct.id && this.editedProduct.name && this.editedProduct.price > 0 && this.editedProduct.description) {
this.productStore.updateProduct({ id: this.editedProduct.id, changes: this.editedProduct });
this.productStore.selectProduct(null); // Clear selection after saving
}
}
cancelEdit(): void {
this.productStore.selectProduct(null); // Clear selection
}
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 = '';
}
trackById(index: number, product: Product): string {
return product.id;
}
}
Step 5.5. Integrate into app.component.ts
// src/app/app.component.ts
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import { ProductCatalogComponent } from './products/product-catalog/product-catalog.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, RouterOutlet, ProductCatalogComponent],
template: `
<div class="container">
<app-product-catalog></app-product-catalog>
</div>
`,
styles: [`
.container {
font-family: Arial, sans-serif;
text-align: center;
margin-top: 20px;
}
`]
})
export class AppComponent {
title = 'my-ngrx-app';
}
Run the application:
ng serve -o
You should now see your Product Catalog with full CRUD functionality, all powered by the NgRx Signal Store!
Project 2: Simplified User Authentication with Traditional NgRx Store
Objective: Implement a basic user authentication flow using the traditional NgRx Store (Actions, Reducers, Effects, Selectors). This will cover login, logout, and maintaining user session.
Features:
- Login (simulate API call).
- Logout.
- Display login status (logged in/out).
- Show user data if logged in.
- Handle loading and error states for authentication.
Prerequisites:
- An Angular v20 project.
- NgRx Store dependencies (
@ngrx/store,@ngrx/effects).
Step-by-Step Guide:
Step 5.6. Define Auth Model
Create src/app/auth/auth.model.ts:
// src/app/auth/auth.model.ts
export interface User {
id: string;
username: string;
token: string;
}
export interface AuthCredentials {
username: string;
password?: string; // Optional for mock service
}
Step 5.7. Create Auth Actions
Create src/app/auth/auth.actions.ts:
// src/app/auth/auth.actions.ts
import { createAction, props } from '@ngrx/store';
import { AuthCredentials, User } from './auth.model';
export const login = createAction(
'[Auth] Login',
props<{ credentials: AuthCredentials }>()
);
export const loginSuccess = createAction(
'[Auth API] Login Success',
props<{ user: User }>()
);
export const loginFailure = createAction(
'[Auth API] Login Failure',
props<{ error: string }>()
);
export const logout = createAction(
'[Auth] Logout'
);
export const logoutSuccess = createAction(
'[Auth API] Logout Success'
);
export const logoutFailure = createAction(
'[Auth API] Logout Failure',
props<{ error: string }>()
);
Step 5.8. Create Auth Reducer
Create src/app/auth/auth.reducer.ts:
// src/app/auth/auth.reducer.ts
import { createReducer, on } from '@ngrx/store';
import * as AuthActions from './auth.actions';
import { User } from './auth.model';
export interface AuthState {
user: User | null;
isLoading: boolean;
error: string | null;
}
export const initialAuthState: AuthState = {
user: null,
isLoading: false,
error: null,
};
export const authReducer = createReducer(
initialAuthState,
on(AuthActions.login, (state) => ({ ...state, isLoading: true, error: null })),
on(AuthActions.loginSuccess, (state, { user }) => ({
...state,
user,
isLoading: false,
error: null,
})),
on(AuthActions.loginFailure, (state, { error }) => ({
...state,
isLoading: false,
error,
user: null, // Ensure user is cleared on login failure
})),
on(AuthActions.logout, (state) => ({ ...state, isLoading: true, error: null })),
on(AuthActions.logoutSuccess, (state) => ({
...state,
user: null,
isLoading: false,
error: null,
})),
on(AuthActions.logoutFailure, (state, { error }) => ({
...state,
isLoading: false,
error,
})),
);
Step 5.9. Create Auth Selectors
Create src/app/auth/auth.selectors.ts:
// src/app/auth/auth.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { AuthState } from './auth.reducer';
export const selectAuthState = createFeatureSelector<AuthState>('auth');
export const selectCurrentUser = createSelector(
selectAuthState,
(state: AuthState) => state.user
);
export const selectIsLoggedIn = createSelector(
selectCurrentUser,
(user) => !!user
);
export const selectAuthIsLoading = createSelector(
selectAuthState,
(state: AuthState) => state.isLoading
);
export const selectAuthError = createSelector(
selectAuthState,
(state: AuthState) => state.error
);
Step 5.10. Create Auth Service (Mock API)
Create src/app/auth/auth.service.ts:
// src/app/auth/auth.service.ts
import { Injectable } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { delay } from 'rxjs/operators';
import { AuthCredentials, User } from './auth.model';
@Injectable({ providedIn: 'root' })
export class AuthService {
login(credentials: AuthCredentials): Observable<User> {
console.log('Auth API: Attempting login for:', credentials.username);
return of(null).pipe(
delay(1000),
switchMap(() => { // Using switchMap here for conditional success/failure
if (credentials.username === 'test' && credentials.password === 'password') {
return of({ id: 'user-1', username: credentials.username, token: 'fake-jwt-token' });
} else {
return throwError(() => new Error('Invalid credentials'));
}
})
);
}
logout(): Observable<boolean> {
console.log('Auth API: Logging out...');
return of(true).pipe(delay(500));
}
}
Step 5.11. Create Auth Effects
Create src/app/auth/auth.effects.ts:
// src/app/auth/auth.effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, map, switchMap, tap } from 'rxjs/operators';
import { of } from 'rxjs';
import * as AuthActions from './auth.actions';
import { AuthService } from './auth.service';
@Injectable()
export class AuthEffects {
constructor(private actions$: Actions, private authService: AuthService) {}
login$ = createEffect(() =>
this.actions$.pipe(
ofType(AuthActions.login),
switchMap(action =>
this.authService.login(action.credentials).pipe(
map(user => AuthActions.loginSuccess({ user })),
catchError(error => of(AuthActions.loginFailure({ error: error.message })))
)
)
)
);
logout$ = createEffect(() =>
this.actions$.pipe(
ofType(AuthActions.logout),
switchMap(() =>
this.authService.logout().pipe(
map(() => AuthActions.logoutSuccess()),
catchError(error => of(AuthActions.logoutFailure({ error: error.message })))
)
)
)
);
}
Step 5.12. Register Auth Module in app.config.ts
// src/app/app.config.ts
import { ApplicationConfig, isDevMode } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideStore } from '@ngrx/store';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { provideEffects } from '@ngrx/effects';
import { routes } from './app.routes';
// Import auth module specifics
import { authReducer } from './auth/auth.reducer';
import { AuthEffects } from './auth/auth.effects';
// Assuming you still have your other reducers/effects
// import { userReducer } from './users/user.reducer';
// import { UserEffects } from './users/user.effects';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideStore({
auth: authReducer, // Register auth reducer
// user: userReducer, // Keep other reducers if needed
}),
provideEffects([
AuthEffects, // Register auth effects
// UserEffects, // Keep other effects if needed
]),
provideStoreDevtools({ maxAge: 25, logOnly: !isDevMode() }),
]
};
Step 5.13. Create an Auth Component
Create src/app/auth/auth/auth.component.ts:
// src/app/auth/auth/auth.component.ts
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';
@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)">
<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: [`
.auth-container {
max-width: 400px;
margin: 50px auto;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
font-family: Arial, sans-serif;
text-align: center;
}
.auth-container form { margin-top: 20px; }
.auth-container input {
display: block;
width: calc(100% - 16px);
padding: 8px;
margin-bottom: 10px;
border-radius: 4px;
border: 1px solid #ccc;
}
.auth-container button {
padding: 10px 20px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.auth-container button:hover { opacity: 0.9; }
`]
})
export class AuthComponent {
private store = inject(Store);
credentials: AuthCredentials = { username: '', password: '' };
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);
onLogin(): void {
this.store.dispatch(AuthActions.login({ credentials: this.credentials }));
// Optionally clear password after dispatch for security
this.credentials.password = '';
}
onLogout(): void {
this.store.dispatch(AuthActions.logout());
}
}
Step 5.14. Integrate into app.component.ts
// src/app/app.component.ts
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import { ProductCatalogComponent } from './products/product-catalog/product-catalog.component';
import { AuthComponent } from './auth/auth/auth.component'; // Import AuthComponent
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, RouterOutlet, ProductCatalogComponent, AuthComponent], // Add AuthComponent
template: `
<div class="container">
<app-auth></app-auth>
<hr>
<app-product-catalog></app-product-catalog>
</div>
`,
styles: [`
.container {
font-family: Arial, sans-serif;
text-align: center;
margin-top: 20px;
}
hr {
margin: 40px auto;
width: 80%;
border-top: 1px solid #eee;
}
`]
})
export class AppComponent {
title = 'my-ngrx-app';
}
Run the application:
ng serve -o
Now you have a basic authentication flow implemented with the traditional NgRx Store, demonstrating how to handle global state that often involves effects for API interactions.
6. Bonus Section: Further Learning and Resources
Congratulations on making it this far! You’ve covered a vast amount of ground in NgRx with Angular v20, from the modern Signal Store to the powerful traditional Store, and even built two guided projects. The journey doesn’t end here; the world of NgRx is rich and constantly evolving.
Here are some excellent resources to continue your learning:
Recommended Online Courses/Tutorials
- NgRx Official Course: The NgRx team occasionally offers official workshops or courses. Keep an eye on their official blog or social media.
- Ultimate Angular (by Angular University): They often have in-depth courses on NgRx that are updated for the latest versions.
- Udemy/Coursera: Search for “Angular NgRx” on these platforms. Look for courses that explicitly mention recent Angular versions (v17+) to ensure relevance. Maximilian Schwarzmüller and Stephen Grider often have highly-rated courses.
- Fireship.io (YouTube): While not a full course, Fireship provides concise and high-energy explanations of complex topics, including NgRx.
Official Documentation
- NgRx Official Documentation: This is your primary and most reliable source of information. It’s well-maintained and always up-to-date.
- NgRx Home
- NgRx Store Guide
- NgRx Signals Guide
- NgRx Migration Guide (for v20) - Essential when upgrading existing projects.
Blogs and Articles
- Angular Addicts Newsletter/Blog: (angularaddicts.com) - Curated monthly Angular resources, often featuring NgRx.
- DEV Community: Many Angular and NgRx experts share their knowledge on dev.to. Search for “NgRx Angular v20” or “NgRx Signal Store.”
- Medium: Similar to DEV Community, many excellent articles can be found on Medium by searching for relevant keywords. Look for authors like Alex Okrushko (from the NgRx core team) and Netanel Basal (creator of
ngrx/signals).
YouTube Channels
- Angular Firebase: Channel by Jeff Delaney, often covers Angular topics with practical examples, including state management.
- Decode Frontend: Alain Chautard’s channel covers various Angular topics, and he often discusses NgRx.
- NgRx Official YouTube Channel: The NgRx team sometimes publishes tutorials or conference talks on their channel.
Community Forums/Groups
- Stack Overflow: The go-to place for specific questions and troubleshooting. Tag your questions with
angularandngrx. - NgRx Discord Server: Many libraries have Discord servers for real-time discussions. Check the official NgRx documentation or community links for an invite.
- Angular Reddit (
r/Angularorr/Angular2): Active community for discussing all things Angular, including state management.
Next Steps/Advanced Topics
- Auth Guards with NgRx: Learn how to protect routes based on authentication state in your store.
- Advanced NgRx Effects: Explore more complex RxJS operators and patterns for managing challenging side effects.
- NgRx Data: If you have many entities and want to drastically reduce boilerplate, dive into
ngrx/data. - State Normalization: Understand how to structure complex state to avoid duplication and improve performance.
- Testing with
TestScheduler: Master advanced testing techniques for effects using RxJSTestScheduler. - Performance Optimization: Deep dive into
OnPushchange detection, memoized selectors, and other performance techniques in conjunction with NgRx. - Integrating with WebSockets: Use NgRx to manage real-time data from WebSocket connections.
- Offline First with NgRx: Explore strategies for building applications that work seamlessly offline using state persistence.
Keep building, keep learning, and don’t be afraid to experiment! NgRx is a powerful tool, and with practice, you’ll master state management in your Angular applications. Happy coding!