NgRx Basics - tutorial01

Okay, let's build a simple Angular standalone application demonstrating core NgRx concepts: Store, Effects, Entity, Component Store, Store DevTools, and the Facade pattern. We'll create a basic "User Management" app where you can load a list of users and add a new user. We'll also add a separate counter component using Component Store. Prerequisites: Node.js and npm/yarn installed. Angular CLI installed (npm install -g @angular/cli). Step 0: Create the Angular Standalone App Open your terminal and run: ng new ngrx-standalone-tutorial --standalone --routing=false --style=css cd ngrx-standalone-tutorial * `--standalone`: Creates the project using the new standalone APIs (no `NgModule`s by default). * `--routing=false`: We don't need routing for this simple example. * `--style=css`: Use plain CSS. Open the project in your favorite code editor. Step 1: Install NgRx Packages Install the necessary NgRx libraries: npm install @ngrx/store @ngrx/effects @ngrx/entity @ngrx/store-devtools @ngrx/component-store --save @ngrx/store: Core state management library. @ngrx/effects: For handling side effects like API calls. @ngrx/entity: For managing collections of data (entities). @ngrx/store-devtools: For debugging the store state with browser extensions. @ngrx/component-store: For local component state management. Step 2: Set up NgRx Store and DevTools Configure the global store and DevTools in your application's bootstrap process. Open src/app/app.config.ts. Import the necessary NgRx providers and configure them. // src/app/app.config.ts import { ApplicationConfig, isDevMode } from '@angular/core'; import { provideStore } from '@ngrx/store'; import { provideEffects } from '@ngrx/effects'; import { provideStoreDevtools } from '@ngrx/store-devtools'; export const appConfig: ApplicationConfig = { providers: [ // Provide the global store provideStore(), // We'll add reducers later using provideState // Provide effects provideEffects([]), // We'll add effects later using provideEffects // Provide Store DevTools (only in development) provideStoreDevtools({ maxAge: 25, // Retains last 25 states logOnly: !isDevMode(), // Restrict extension to log-only mode in production autoPause: true, // Pauses recording actions and state changes when the extension window is not open trace: false, // If set to true, will include stack trace for every dispatched action traceLimit: 75, // Maximum stack trace frames to be stored (in case trace is true) connectInZone: true // If set to true, the connection is established within the Angular zone }), ] }; provideStore(): Initializes the global store container. We'll register feature states later. provideEffects([]): Initializes the effects system. We'll register feature effects later. provideStoreDevtools(): Connects your app to the Redux DevTools browser extension. isDevMode() ensures it's only fully active during development. Step 3: Define the State (User Feature) Let's define the structure for managing our users. Create User Model: mkdir src/app/users touch src/app/users/user.model.ts ```typescript // src/app/users/user.model.ts export interface User { id: string; name: string; email: string; } ``` Define User State using NgRx Entity: touch src/app/users/user.state.ts ```typescript // src/app/users/user.state.ts import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity'; import { User } from './user.model'; // Define the shape of the User feature state // We extend EntityState provided by @ngrx/entity // EntityState gives us { ids: [], entities: {} } structure export interface UserState extends EntityState { // Add other properties for this state slice if needed loading: boolean; error: string | null; selectedUserId: string | null; // Example: if you wanted to track selection } // Create an entity adapter // It provides utility functions to manage the entity collection (add, update, remove, etc.) // Select the primary key (id) // Sort entities by name (optional) export const userAdapter: EntityAdapter = createEntityAdapter({ selectId: (user: User) => user.id, sortComparer: (a: User, b: User): number => a.name.localeCompare(b.name), }); // Define the initial state using the adapter's getInitialState method export const initialUserState: UserState = userAdapter.getInitialState({ // Add initial values for other properties loading: false, error: null, selectedUserId: null, }); ``` * `EntityState`: Provides a standard structure (`ids: string[] | number[]`, `entities: { [id: string | number]: User }`) for storing collections. * `createEntityAdapter`: Creates an adapter with helper functions for common entity operations (add, update, remove, etc.) and selectors. * `userAdapter.getInitialState`: Creates the initial state object, including the `ids` and `entities` properties, p

Apr 11, 2025 - 08:08
 0
NgRx Basics - tutorial01

Okay, let's build a simple Angular standalone application demonstrating core NgRx concepts: Store, Effects, Entity, Component Store, Store DevTools, and the Facade pattern.

We'll create a basic "User Management" app where you can load a list of users and add a new user. We'll also add a separate counter component using Component Store.

Prerequisites:

  • Node.js and npm/yarn installed.
  • Angular CLI installed (npm install -g @angular/cli).

Step 0: Create the Angular Standalone App

  1. Open your terminal and run:

    ng new ngrx-standalone-tutorial --standalone --routing=false --style=css
    cd ngrx-standalone-tutorial
    
* `--standalone`: Creates the project using the new standalone APIs (no `NgModule`s by default).
* `--routing=false`: We don't need routing for this simple example.
* `--style=css`: Use plain CSS.
  1. Open the project in your favorite code editor.

Step 1: Install NgRx Packages

Install the necessary NgRx libraries:

npm install @ngrx/store @ngrx/effects @ngrx/entity @ngrx/store-devtools @ngrx/component-store --save
  • @ngrx/store: Core state management library.
  • @ngrx/effects: For handling side effects like API calls.
  • @ngrx/entity: For managing collections of data (entities).
  • @ngrx/store-devtools: For debugging the store state with browser extensions.
  • @ngrx/component-store: For local component state management.

Step 2: Set up NgRx Store and DevTools

Configure the global store and DevTools in your application's bootstrap process.

  1. Open src/app/app.config.ts.
  2. Import the necessary NgRx providers and configure them.
// src/app/app.config.ts
import { ApplicationConfig, isDevMode } from '@angular/core';
import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { provideStoreDevtools } from '@ngrx/store-devtools';

export const appConfig: ApplicationConfig = {
  providers: [
    // Provide the global store
    provideStore(), // We'll add reducers later using provideState

    // Provide effects
    provideEffects([]), // We'll add effects later using provideEffects

    // Provide Store DevTools (only in development)
    provideStoreDevtools({
      maxAge: 25, // Retains last 25 states
      logOnly: !isDevMode(), // Restrict extension to log-only mode in production
      autoPause: true, // Pauses recording actions and state changes when the extension window is not open
      trace: false, // If set to true, will include stack trace for every dispatched action
      traceLimit: 75, // Maximum stack trace frames to be stored (in case trace is true)
      connectInZone: true // If set to true, the connection is established within the Angular zone
    }),
  ]
};
  • provideStore(): Initializes the global store container. We'll register feature states later.
  • provideEffects([]): Initializes the effects system. We'll register feature effects later.
  • provideStoreDevtools(): Connects your app to the Redux DevTools browser extension. isDevMode() ensures it's only fully active during development.

Step 3: Define the State (User Feature)

Let's define the structure for managing our users.

  1. Create User Model:

    mkdir src/app/users
    touch src/app/users/user.model.ts
    
```typescript
// src/app/users/user.model.ts
export interface User {
  id: string;
  name: string;
  email: string;
}
```
  1. Define User State using NgRx Entity:

    touch src/app/users/user.state.ts
    
```typescript
// src/app/users/user.state.ts
import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity';
import { User } from './user.model';

// Define the shape of the User feature state
// We extend EntityState provided by @ngrx/entity
// EntityState gives us { ids: [], entities: {} } structure
export interface UserState extends EntityState {
  // Add other properties for this state slice if needed
  loading: boolean;
  error: string | null;
  selectedUserId: string | null; // Example: if you wanted to track selection
}

// Create an entity adapter
// It provides utility functions to manage the entity collection (add, update, remove, etc.)
// Select the primary key (id)
// Sort entities by name (optional)
export const userAdapter: EntityAdapter = createEntityAdapter({
  selectId: (user: User) => user.id,
  sortComparer: (a: User, b: User): number => a.name.localeCompare(b.name),
});

// Define the initial state using the adapter's getInitialState method
export const initialUserState: UserState = userAdapter.getInitialState({
  // Add initial values for other properties
  loading: false,
  error: null,
  selectedUserId: null,
});
```
* `EntityState`: Provides a standard structure (`ids: string[] | number[]`, `entities: { [id: string | number]: User }`) for storing collections.
* `createEntityAdapter`: Creates an adapter with helper functions for common entity operations (add, update, remove, etc.) and selectors.
* `userAdapter.getInitialState`: Creates the initial state object, including the `ids` and `entities` properties, plus any custom properties we defined (`loading`, `error`).

Step 4: Define Actions

Actions describe unique events that happen in your application (e.g., user clicked "Load Users", data loaded successfully).

touch src/app/users/user.actions.ts
// src/app/users/user.actions.ts
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import { User } from './user.model';

// Use createActionGroup for concise action definitions
export const UserActions = createActionGroup({
  source: 'Users API', // Unique source for these actions
  events: {
    // Command Actions (Triggering side effects or direct state changes)
    'Load Users': emptyProps(), // Action to initiate loading users
    'Add User': props<{ user: Omit<User, 'id'> }>(), // Action to initiate adding a user

    // Document Actions (Result of side effects)
    'Load Users Success': props<{ users: User[] }>(), // Users loaded successfully
    'Load Users Failure': props<{ error: string }>(), // Failed to load users

    'Add User Success': props<{ user: User }>(), // User added successfully
    'Add User Failure': props<{ error: string }>(), // Failed to add user
  },
});
  • createActionGroup: A helper function to create multiple related actions with a common source. This reduces boilerplate.
  • emptyProps(): For actions that don't carry a payload.
  • props<{...}>(): For actions that carry a payload, defining the payload's type.

Step 5: Create the Reducer

The reducer is a pure function that takes the current state and an action, and returns the new state. It determines how the state changes in response to actions.

touch src/app/users/user.reducer.ts
// src/app/users/user.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { UserActions } from './user.actions';
import { initialUserState, userAdapter, UserState } from './user.state';

export const userFeatureKey = 'users'; // Unique key for this feature state

export const userReducer = createReducer(
  initialUserState,

  // Handle Load Users action
  on(UserActions.loadUsers, (state): UserState => ({
      ...state,
      loading: true,
      error: null,
  })),

  // Handle Load Users Success action
  on(UserActions.loadUsersSuccess, (state, { users }): UserState =>
    // Use the adapter's setAll method to replace the existing collection
    userAdapter.setAll(users, {
        ...state,
        loading: false,
        error: null,
    })
  ),

  // Handle Load Users Failure action
  on(UserActions.loadUsersFailure, (state, { error }): UserState => ({
      ...state,
      loading: false,
      error: error,
  })),

  // Handle Add User action (Optimistic UI: assume success, handle failure later if needed)
   on(UserActions.addUser, (state): UserState => ({
      ...state,
      loading: true, // Indicate loading while adding
      error: null,
  })),

  // Handle Add User Success action
  on(UserActions.addUserSuccess, (state, { user }): UserState =>
    // Use the adapter's addOne method to add the new user
    userAdapter.addOne(user, {
      ...state,
      loading: false, // Stop loading indicator
    })
  ),

  // Handle Add User Failure action
  on(UserActions.addUserFailure, (state, { error }): UserState => ({
      ...state,
      loading: false, // Stop loading indicator
      error: error,
  }))

  // Add more 'on' handlers for other actions as needed
);

  • createReducer: Creates the reducer function.
  • initialUserState: The starting state.
  • on(Action, (state, payload) => newState): Defines how the state should change for a specific action.
  • userAdapter.setAll: Replaces all entities in the state.
  • userAdapter.addOne: Adds a single new entity to the state. The adapter handles updating ids and entities immutably.
  • userFeatureKey: A unique string identifier for this slice of state within the global store.

Step 6: Create Selectors

Selectors are pure functions used to get slices of state from the store. They provide memoization, meaning they only recalculate if the relevant part of the state has changed.

touch src/app/users/user.selectors.ts
// src/app/users/user.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { userAdapter, UserState, userFeatureKey } from './user.state';

// 1. Feature Selector: Selects the 'users' feature state slice
export const selectUserState = createFeatureSelector<UserState>(userFeatureKey);

// 2. Entity Adapter Selectors: Use adapter's built-in selectors
const {
  selectAll, // Selects the array of users
  selectEntities, // Selects the dictionary of users
  selectIds, // Selects the array of user ids
  selectTotal, // Selects the total count of users
} = userAdapter.getSelectors(selectUserState); // Pass the feature selector here

// 3. Expose the selectors you need
export const selectAllUsers = selectAll;
export const selectUserEntities = selectEntities;
export const selectUserIds = selectIds;
export const selectUserTotal = selectTotal;

// 4. Custom Selectors: Create selectors for specific derived data
export const selectUserLoading = createSelector(
  selectUserState,
  (state: UserState) => state.loading // Select the loading flag
);

export const selectUserError = createSelector(
  selectUserState,
  (state: UserState) => state.error // Select the error message
);

// Example: Selector to get a specific user by ID (if needed)
// export const selectUserById = (userId: string) => createSelector(
//   selectUserEntities,
//   (entities) => entities[userId]
// );
  • createFeatureSelector: Creates a selector for a top-level feature state (identified by userFeatureKey).
  • userAdapter.getSelectors: Provides pre-built, efficient selectors for common entity operations (selectAll, selectTotal, etc.). You must pass your feature selector to it.
  • createSelector: Creates memoized selectors. You can combine other selectors to derive specific data.

Step 7: Create Effects

Effects handle side effects, such as fetching data from an API, based on dispatched actions. They listen for specific actions, perform the side effect, and dispatch new actions (success or failure).

  1. Create a Mock User Service:

    touch src/app/users/user.service.ts
    
```typescript
// src/app/users/user.service.ts
import { Injectable } from '@angular/core';
import { Observable, of, delay, throwError, map } from 'rxjs';
import { User } from './user.model';

// Simulate API latency
const API_DELAY = 500;

@Injectable({
  providedIn: 'root', // Service available globally
})
export class UserService {
  private users: User[] = [
    { id: '1', name: 'Alice', email: 'alice@example.com' },
    { id: '2', name: 'Bob', email: 'bob@example.com' },
  ];

  // Simulate GET /users
  getUsers(): Observable {
    console.log('UserService: Fetching users...');
    return of(this.users).pipe(delay(API_DELAY));
    // Simulate error (uncomment to test failure case):
    // return throwError(() => new Error('Failed to fetch users')).pipe(delay(API_DELAY));
  }

  // Simulate POST /users
  addUser(userData: Omit): Observable {
    console.log('UserService: Adding user...', userData);
    // Simulate error (uncomment to test failure case):
    // return throwError(() => new Error('Failed to add user')).pipe(delay(API_DELAY));

    const newUser: User = {
      id: Date.now().toString(), // Generate a simple unique ID
      ...userData,
    };
    this.users = [...this.users, newUser]; // Add to our mock DB (immutable update)
    return of(newUser).pipe(delay(API_DELAY));
  }
}
```
  1. Create User Effects:

    touch src/app/users/user.effects.ts
    
```typescript
// src/app/users/user.effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, map, mergeMap, of, switchMap } from 'rxjs';
import { UserService } from './user.service';
import { UserActions } from './user.actions';

@Injectable()
export class UserEffects {
  // Effect to handle loading users
  loadUsers$ = createEffect(() =>
    this.actions$.pipe(
      ofType(UserActions.loadUsers), // Listen for the 'Load Users' action
      mergeMap(() => // Use mergeMap for parallel requests (or switchMap to cancel previous)
        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
        )
      )
    )
  );

  // Effect to handle adding a user
  addUser$ = createEffect(() =>
      this.actions$.pipe(
          ofType(UserActions.addUser), // Listen for the 'Add User' action
          switchMap(({ user }) => // Use switchMap or mergeMap based on desired behavior
              this.userService.addUser(user).pipe(
                  map(newUser => UserActions.addUserSuccess({ user: newUser })), // On success, dispatch success action
                  catchError(error => of(UserActions.addUserFailure({ error: error.message }))) // On error, dispatch failure action
              )
          )
      )
  );


  // Inject Actions stream and UserService
  constructor(
    private actions$: Actions,
    private userService: UserService
  ) {}
}
```
* `@Injectable()`: Effects are services.
* `createEffect`: Defines an effect. It takes a function that returns an Observable.
* `actions$`: An observable stream of all dispatched actions.
* `ofType(ActionType)`: Filters the actions stream for specific action types.
* `mergeMap`, `switchMap`, `concatMap`, `exhaustMap`: RxJS operators to handle how incoming actions are mapped to service calls (e.g., handle concurrently, cancel previous, queue). `mergeMap` or `switchMap` are common.
* `map`: Transforms the successful result from the service into a success action.
* `catchError`: Catches errors from the service call and transforms them into a failure action using `of()`.

Step 8: Implement the Facade (UserFacade)

A facade is an optional but often helpful abstraction layer. It simplifies interaction with the store by exposing specific state slices as observables and providing methods to dispatch actions. Components interact with the facade instead of directly with the Store.

touch src/app/users/user.facade.ts
// src/app/users/user.facade.ts
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { User } from './user.model';
import { UserActions } from './user.actions';
import * as UserSelectors from './user.selectors'; // Import all selectors

@Injectable({ providedIn: 'root' }) // Often provided globally or at feature level
export class UserFacade {
  // Expose selectors as observables
  readonly allUsers$: Observable<User[]> = this.store.select(UserSelectors.selectAllUsers);
  readonly loading$: Observable<boolean> = this.store.select(UserSelectors.selectUserLoading);
  readonly error$: Observable<string | null> = this.store.select(UserSelectors.selectUserError);
  readonly totalUsers$: Observable<number> = this.store.select(UserSelectors.selectUserTotal);

  constructor(private store: Store) {} // Inject the global store

  // Expose methods to dispatch actions
  loadUsers(): void {
    this.store.dispatch(UserActions.loadUsers());
  }

  addUser(user: Omit<User, 'id'>): void {
    this.store.dispatch(UserActions.addUser({ user }));
  }

  // Add more methods as needed for other actions
}
  • Injects Store.
  • Uses store.select() with the selectors to expose state streams.
  • Provides public methods that dispatch actions. The component doesn't need to know about specific actions, only about the facade's methods.

Step 9: Build the Component (user-list.component.ts)

Now, create the Angular component to display and interact with the user data.

ng generate component users/user-list --standalone

Modify the generated files:

// src/app/users/user-list/user-list.component.ts
import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core';
import { AsyncPipe, JsonPipe, NgFor, NgIf } from '@angular/common';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; // Import forms modules

import { UserFacade } from '../user.facade'; // Import the Facade
import { provideState } from '@ngrx/store'; // Import provideState
import { provideEffects } from '@ngrx/effects'; // Import provideEffects
import { userFeatureKey, userReducer } from '../user.reducer'; // Import feature reducer/key
import { UserEffects } from '../user.effects'; // Import feature effects

@Component({
  selector: 'app-user-list',
  standalone: true,
  imports: [
    NgIf, // Add NgIf and NgFor
    NgFor,
    AsyncPipe, // Add AsyncPipe for observables
    JsonPipe, // Add JsonPipe for debugging (optional)
    ReactiveFormsModule // Add ReactiveFormsModule
  ],
  templateUrl: './user-list.component.html',
  styleUrls: ['./user-list.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush, // Use OnPush for performance with NgRx
  providers: [
    // Register the feature state and effects specifically for this component's scope
    // or provide them globally/at a route level if shared across many components.
    // For this simple example, we provide them here.
    provideState(userFeatureKey, userReducer),
    provideEffects([UserEffects]),
    // UserFacade is already providedIn: 'root'
  ]
})
export class UserListComponent implements OnInit {
  // Inject the Facade
  constructor(public userFacade: UserFacade, private fb: FormBuilder) {}

  // Form to add a new user
  addUserForm = this.fb.group({
    name: ['', Validators.required],
    email: ['', [Validators.required, Validators.email]],
  });

  ngOnInit(): void {
    // Dispatch action via facade on component initialization
    this.userFacade.loadUsers();
  }

  onSubmit(): void {
    if (this.addUserForm.valid) {
      const { name, email } = this.addUserForm.value;
      if (name && email) { // Type assertion needed due to partial value possibility
          // Dispatch action via facade
          this.userFacade.addUser({ name, email });
          this.addUserForm.reset(); // Clear the form
      }
    }
  }
}

User Management (NgRx Global Store + Entity + Effects + Facade)

*ngIf="userFacade.loading$ | async">Loading users...
*ngIf="userFacade.error$ | async as error" style="color: red;"> Error: {{ error }}
*ngIf="(userFacade.allUsers$ | async)?.length === 0 && !(userFacade.loading$ | async)"> No users found.