Mastering NgRx with Angular v20: A Comprehensive Beginner's Guide

// table of contents

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:

  1. 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
    
  2. npm (Node Package Manager) or Yarn/Bun: npm is installed automatically with Node.js. You can check its version:
    npm -v
    
    Alternatively, you can use Yarn or Bun for package management. For this guide, we’ll primarily use npm.
  3. 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:
    npm install -g @angular/cli@20
    
    Verify the installation:
    ng version
    
  4. 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:

  1. Create a new Angular project: Open your terminal and run:

    ng new my-ngrx-app --standalone --routing --style=css
    
    • my-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 choose scss, sass, or less if 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.

  2. Navigate into your project directory:

    cd my-ngrx-app
    
  3. Serve the application:

    ng serve -o
    

    This 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 with count: 0.
  • withComputed(...): Defines derived signals. doubleCount and isEven will automatically re-calculate when count() 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 the CounterStore using Angular’s inject function, which is a modern way to access services.
  • {{ counterStore.count() }}: We access the count signal directly using its value accessor (). Since signals are reactive, any change to counterStore.count() will automatically update the UI.
  • counterStore.increment(): We call the methods defined in our store to modify the state.

Exercises/Mini-Challenges

  1. Add a multiplyBy method:
    • In counter.store.ts, add a new method multiplyBy(value: number). This method should multiply the current count by the value passed.
    • Add a new button in app.component.html that calls counterStore.multiplyBy(2).
  2. Add a new computed signal:
    • In counter.store.ts, add a isNegative computed signal that returns true if count is less than 0, and false otherwise.
    • Display this isNegative status in app.component.html.

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 to CounterState to track the status of asynchronous operations.
  • apiService = inject(ApiService): The ApiService is injected into the withMethods factory.
  • loadCount: rxMethod<void>(...):
    • rxMethod<void>: Declares a method that takes no arguments.
    • tap(() => patchState(store, { isLoading: true, error: null })): Before the API call, set isLoading to true and clear any previous errors.
    • switchMap(() => apiService.loadInitialCount().pipe(...)): When loadCount() is called, it triggers the loadInitialCount observable. switchMap unsubscribes from previous emissions if loadCount is called again quickly.
    • tapResponse({ next, error, finalize }): A convenient operator from @ngrx/operators to handle the different outcomes of an observable:
      • next: On successful data retrieval, update count.
      • error: On error, set the error message.
      • finalize: Always reset isLoading to false, 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

  1. Add an updateCountAsync method:
    • Create a new rxMethod in counter.store.ts called updateCountAsync(newValue: number). This method should simulate an API call that “saves” the new value after 2 seconds.
    • It should set isLoading to true and error to null initially.
    • On success, patchState with the newValue. On error, set an appropriate error message.
    • Add an input field and a button in app.component.html to allow the user to type a number and call updateCountAsync with that number.
  2. Handle distinct values and debounce:
    • Modify updateCountAsync to use debounceTime(500) and distinctUntilChanged() (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.

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 on Todo items.
  • withEntities<Todo>(): This is the key. It sets up the entity management. It requires the type of entity (Todo).
  • setAllEntities(todos): Used in loadAllTodos to replace the entire collection.
  • addEntity(todo): Used in addTodo to add a single new entity.
  • updateEntity({ id: updatedTodo.id, changes: { completed: updatedTodo.completed } }): Used in updateTodoStatus to update an existing entity by ID with specific changes.
  • removeEntity(deletedId): Used in deleteTodo to 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

  1. Implement upsertEntity for a detailed edit:
    • Currently, saveEditedTodo uses addOrUpdateTodo which calls upsertEntity. Modify the TodoApiService.updateTodo method 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 the upsertEntity functionality.
  2. Add a “Clear Completed” button:
    • Add a new rxMethod in TodosStore called clearCompletedTodos. This method should filter out all completed todos and update the state using setAllEntities.
    • Add a button in TodoListComponent that calls this method.

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 of StoreFeatures (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 to computed but 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 the signalStore definition. Its state and methods become available on the TodosStore instance.
  • store.setLoading(true) / store.setError(err.message): We now use the methods provided by withLoadingAndError instead of patchState directly for loading and error states. This makes our TodosStore methods cleaner and the loading/error logic reusable.
  • withLinkedState(...): We added a selectedTodoId to our TodosState. withLinkedState then uses this selectedTodoId and the entities from withEntities to create a new selectedTodo signal that automatically holds the full Todo object 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

  1. Create a withTimestamp feature:
    • Create a new signalStoreFeature called withTimestamp that adds a lastUpdated state property (a Date object) and a updateTimestamp method that sets lastUpdated to new Date().
    • Add this withTimestamp feature to your TodosStore.
    • Call store.updateTimestamp() inside the finalize block of loadAllTodos, addTodo, updateTodoStatus, and deleteTodo in TodosStore.
    • Display the lastUpdated timestamp in your TodoListComponent.
  2. Explore withFeature:
    • Imagine a withLogger feature that takes the store name as an input. Can you create such a feature and integrate it? (Hint: withFeature allows you to pass arguments to the feature factory).

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 that loadUsersSuccess action will carry a users array of type User[].

Exercises/Mini-Challenges

  1. Define more actions:
    • Add actions for updateUser (with id and changes), updateUserSuccess, updateUserFailure.
    • Add actions for deleteUser (with id), deleteUserSuccess, deleteUserFailure.
    • Consider actions for selecting a user (e.g., selectUser, clearSelectedUser).

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 })): When loadUsers action is dispatched, it returns a new state object with isLoading set to true and error cleared. 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 the users array.

Exercises/Mini-Challenges

  1. Complete the reducer for other actions:
    • Add on handlers for updateUserSuccess, 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 the users array.
    • Add an on handler for selectUser to update selectedUserId and clearSelectedUser to set it to null.

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 the userReducer under the key user in your global state. Now, your application’s state will have a user property 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

  1. Add addUser functionality:
    • In UserListComponent, add an input field and a button to allow adding new users (just name and email).
    • Dispatch the UserActions.addUser action with the new user data.
    • Remember that the addUserSuccess action will be handled by the reducer to actually add the user to the state.
  2. 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 entire user slice of your global state.
  • createSelector(selectUserState, (state: UserState) => state.users): This selector takes selectUserState as an input and projects (extracts) the users array from it.
  • createSelector(selectUsers, (users) => users.length): This is a composed selector. It takes the selectUsers selector’s output and calculates the length. This is a powerful pattern for deriving new data from existing state.

Exercises/Mini-Challenges

  1. Create more derived selectors:
    • Create a selector selectActiveUsers that filters out users based on some criteria (e.g., if you add an isActive property to User).
    • Create a selector selectUserNames that returns an array of only user names.
  2. Use selectSelectedUser:
    • In your UserListComponent, add an “Edit” button next to each user. When clicked, it should dispatch a selectUser action (you need to define this action and handle it in the reducer to update selectedUserId).
    • Then, display the selectedUser$ (from your new selector) in an editable form below the list.
    • Add a “Save” button to dispatch updateUser and a “Cancel” button to dispatch clearSelectedUser.

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 for loadUsers actions.
  • switchMap(() => this.userService.getUsers().pipe(...)): When loadUsers is dispatched, it calls userService.getUsers().
    • switchMap is used here because if loadUsers is dispatched multiple times before the previous API call completes, switchMap will 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) or mergeMap (runs in parallel) might be more appropriate.
  • map(users => UserActions.loadUsersSuccess({ users })): On successful response from the UserService, map the data to a loadUsersSuccess action and dispatch it.
  • catchError(error => of(UserActions.loadUsersFailure({ error: error.message }))): If the UserService observable errors, catch it and dispatch a loadUsersFailure action.
  • { 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

  1. Refactor with concatMap vs switchMap:
    • Currently addUser$ uses concatMap. Change it to switchMap and 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$ to concatMap and consider if it’s suitable for loading data.
  2. Add a global notification effect:
    • Create a new effect that listens for all *Success actions (e.g., loadUsersSuccess, addUserSuccess) and dispatches a generic NotificationActions.showSuccessNotification with a message.
    • Similarly, for *Failure actions, dispatch NotificationActions.showErrorNotification.
    • (This would involve creating a new NotificationModule with its own actions, reducer, and a component to display notifications).

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).
      • rxMethod simplifies 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 OnPush strategy 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
    
  • patchState in Signal Store: As seen, patchState internally handles immutable updates for you.
  • addEntity, updateEntity, removeEntity from @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) within rxMethod’s switchMap/concatMap to handle next, error, and finalize logic.
    • Store the error message in your store’s state (error: string | null) and display it in the UI.
  • In Traditional NgRx Store (with Effects):
    • Always use catchError within your effects’ observable pipes.
    • On error, dispatch a specific Failure action 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 *Failure actions and displays a toast notification or logs the error to an analytics service.

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);
        });
      });
      
  • 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 TestScheduler from rxjs/testing or provideMockActions from @ngrx/effects/testing to mock the Actions stream 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.
  • 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);
    }
    

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/store and @ngrx/effects that automates most of the boilerplate for managing entity collections. If you have many similar entity CRUD operations, ngrx/data can 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-store as 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:

  • 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

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 angular and ngrx.
  • 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/Angular or r/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 RxJS TestScheduler.
  • Performance Optimization: Deep dive into OnPush change 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!