7. BONUS: Using the NGRX Signal Store

🔀 Branch: For this lab, please switch to the branch 08-signal-store-starter:

git reset --hard
git checkout 08-signal-store-starter

In this lab, we switch from our hand-made store to the NGRX Signal Store.

7.1 Switching to the Signal Store

Open the file dessert.store.ts (src/app/data/dessert.store.ts). It contains a new version of our store using the NGRX Signal Store. Try to find out which properties and methods it provides.

7.2 Using RxJS with the Signal Store

Now, let's use the Signal Store's RxJS-interop layer to implement a typeahead.

  1. Open the file dessert.store.ts (src/app/data/dessert.store.ts) and add an rxMethod called loadDessertsByFilter:

     import { computed, inject } from '@angular/core';
    +import { tapResponse } from '@ngrx/operators';
     import {
       patchState,
       signalStore,
       withComputed,
    +  withHooks,
       withMethods,
       withState,
     } from '@ngrx/signals';
    +import { rxMethod } from '@ngrx/signals/rxjs-interop';
    +import { debounceTime, filter, pipe, switchMap, tap } from 'rxjs';
     import { ToastService } from '../shared/toast';
     import { Dessert } from './dessert';
     import { DessertFilter } from './dessert-filter';
    
    [...]
    
       withState({
         filter: {
           originalName: '',
    -      englishName: '',
    +      englishName: 'Cake',
         },
         loading: false,
         ratings: {} as DessertIdToRatingMap,
    
    [...]
    
           updateFilter(filter: DessertFilter): void {
             patchState(store, { filter });
           },
    -      loadDesserts(): void {
    -        patchState(store, { loading: true });
    -
    -        dessertService.find(store.filter()).subscribe({
    -          next: (desserts) => {
    -            patchState(store, { desserts, loading: false });
    -          },
    -          error: (error) => {
    -            patchState(store, { loading: false });
    -            toastService.show('Error loading desserts!');
    -            console.error(error);
    -          },
    -        });
    -      },
           loadRatings(): void {
             patchState(store, { loading: true });
    
    [...]
    
               },
             }));
           },
    +      loadDessertsByFilter: rxMethod<DessertFilter>(
    +        pipe(
    +          filter(
    +            (f) => f.originalName.length >= 3 || f.englishName.length >= 3,
    +          ),
    +          debounceTime(300),
    +          tap(() => patchState(store, { loading: true })),
    +          switchMap((f) =>
    +            dessertService.find(f).pipe(
    +              tapResponse({
    +                next: (desserts) => {
    +                  patchState(store, { desserts, loading: false });
    +                },
    +                error: (error) => {
    +                  toastService.show('Error loading desserts!');
    +                  console.error(error);
    +                  patchState(store, { loading: false });
    +                },
    +              }),
    +            ),
    +          ),
    +        ),
    +      ),
         }),
       ),
    +  withHooks({
    +    onInit(store) {
    +      const filter = store.filter;
    +      store.loadDessertsByFilter(filter);
    +    },
    +  }),
     );
    

    Remarks: Make sure the rxMethod is signaled whenever the search filter changes. For this, you can add an onInit hook.

    Further remarks: The call to filter and debounceTime could be moved to the component to make the store more reusable. For the sake of simplicity, we keep it here during this lab.

  2. Switch to the file desserts.component.ts (src/app/desserts/desserts.component.ts) and remove all calls to loadDesserts as loading is now directly triggered by a filter change:

     export class DessertsComponent {
       ratedDesserts = this.#store.ratedDesserts;
       loading = this.#store.loading;
     
    -  constructor() {
    -    this.#store.loadDesserts();
    -  }
    -
    -  search(): void {
    -    this.#store.loadDesserts();
    -  }
    -
       loadRatings(): void {
         this.#store.loadRatings();
       }
    
  3. Open the file desserts.component.html (src/app/desserts/desserts.component.html) and remove the Search button:

     </div>
       </div>
       <div class="mt-10 mb-10">
    -    <button
    -      type="submit"
    -      (click)="search()"
    -      class="btn btn-primary"
    -      [disabled]="loading()"
    -    >
    -      Search
    -    </button>
    -    <button
    -      type="button"
    -      (click)="loadRatings()"
    -      class="btn ml-2"
    -      [disabled]="loading()"
    -    >
    +    <button type="button" (click)="loadRatings()" class="btn">
           Expert Ratings
         </button>
    
  4. Try out your solution.


💾 Please find this lab's solution in the branch 08a-signal-store-rxjs (get reset --hard && git checkout 08a-signal-store-rxjs). You don't have to switch to it branch, if you are fine.

7.3 Bonus: Adding a Custom Signal Store Feature

One of the strengths of the Signal Store is the possibility to create reusable features for repeating requirements. In this example, we move some of our logic for loading entities like desserts to a reusable custom feature.

🔀 Branch: For this lab, please switch to the branch 09-custom-feature-starter:

git reset --hard
git checkout 09-custom-feature-starter
  1. Have a look to the DessertStore (src/app/data/dessert.store.ts).

    Remarks: The store was refactored so that all features (withState, withMethods, withHooks) that deal with desserts and are about to be moved into a reusable feature are located before the first comment.

  2. Crate a file data-service.feature.ts (src/app/data/data-service.feature.ts) and add a custom feature grouping the before mentioned features:

    import { inject } from '@angular/core';
    import { tapResponse } from '@ngrx/operators';
    import {
      patchState,
      signalStoreFeature,
      withHooks,
      withMethods,
      withState,
    } from '@ngrx/signals';
    import { rxMethod } from '@ngrx/signals/rxjs-interop';
    import { debounceTime, filter, pipe, switchMap, tap } from 'rxjs';
    import { ToastService } from '../shared/toast';
    import { Dessert } from './dessert';
    import { DessertFilter } from './dessert-filter';
    import { DessertService } from './dessert.service';
    
    export function withDataService() {
      return signalStoreFeature(
        withState({
          filter: {
            originalName: '',
            englishName: 'Cake',
          },
          loading: false,
          desserts: [] as Dessert[],
        }),
        withMethods(
          (
            store,
            dessertService = inject(DessertService),
            toastService = inject(ToastService),
          ) => ({
            updateFilter(filter: DessertFilter): void {
              patchState(store, { filter });
            },
            loadDessertsByFilter: rxMethod<DessertFilter>(
              pipe(
                filter(
                  (f) => f.originalName.length >= 3 || f.englishName.length >= 3,
                ),
                debounceTime(300),
                tap(() => patchState(store, { loading: true })),
                switchMap((f) =>
                  dessertService.find(f).pipe(
                    tapResponse({
                      next: (desserts) => {
                        patchState(store, { desserts, loading: false });
                      },
                      error: (error) => {
                        toastService.show('Error loading desserts!');
                        console.error(error);
                        patchState(store, { loading: false });
                      },
                    }),
                  ),
                ),
              ),
            ),
          }),
        ),
        withHooks({
          onInit(store) {
            const filter = store.filter;
            store.loadDessertsByFilter(filter);
          },
        }),
      );
    }
    
  3. Switch to the file dessert.store.ts (src/app/data/dessert.store.ts) and replace the discussed features with our custom feature:

     import { computed, inject } from '@angular/core';
    -import { tapResponse } from '@ngrx/operators';
     import {
       patchState,
       signalStore,
       withComputed,
    -  withHooks,
       withMethods,
       withState,
     } from '@ngrx/signals';
    -import { rxMethod } from '@ngrx/signals/rxjs-interop';
    -import { debounceTime, filter, pipe, switchMap, tap } from 'rxjs';
     import { ToastService } from '../shared/toast';
    -import { Dessert } from './dessert';
    -import { DessertFilter } from './dessert-filter';
    -import { DessertService } from './dessert.service';
    +import { withDataService } from './data-service.feature';
     import { DessertIdToRatingMap, RatingService } from './rating.service';
     import { toRated } from './to-rated';
     
     export const DessertStore = signalStore(
       { providedIn: 'root' },
    -  withState({
    -    filter: {
    -      originalName: '',
    -      englishName: 'Cake',
    -    },
    -    loading: false,
    -    desserts: [] as Dessert[],
    -  }),
    -  withMethods(
    -    (
    -      store,
    -      dessertService = inject(DessertService),
    -      toastService = inject(ToastService),
    -    ) => ({
    -      updateFilter(filter: DessertFilter): void {
    -        patchState(store, { filter });
    -      },
    -      loadDessertsByFilter: rxMethod<DessertFilter>(
    -        pipe(
    -          filter(
    -            (f) => f.originalName.length >= 3 || f.englishName.length >= 3,
    -          ),
    -          debounceTime(300),
    -          tap(() => patchState(store, { loading: true })),
    -          switchMap((f) =>
    -            dessertService.find(f).pipe(
    -              tapResponse({
    -                next: (desserts) => {
    -                  patchState(store, { desserts, loading: false });
    -                },
    -                error: (error) => {
    -                  toastService.show('Error loading desserts!');
    -                  console.error(error);
    -                  patchState(store, { loading: false });
    -                },
    -              }),
    -            ),
    -          ),
    -        ),
    -      ),
    -    }),
    -  ),
    -  withHooks({
    -    onInit(store) {
    -      const filter = store.filter;
    -      store.loadDessertsByFilter(filter);
    -    },
    -  }),
    +
    +  withDataService(),
     
       //
       // Ratings-realated parts have been moved down
    
  4. Try our your modifications


💾 Please find this lab's solution in the branch 09a-add-custom-feature (get reset --hard && git checkout 09a-add-custom-feature). You don't have to switch to it branch, if you are fine.

7.4 Bonus: Generic Custom Feature

Now, let's inspect a generic version of our custom feature.

🔀 Branch: For this lab, please switch to the branch 09a-add-custom-feature:

git reset --hard
git checkout 09a-add-custom-feature
  1. Have a look at the file data-service.feature.ts (src/app/data/data-service.feature.ts). It contains a generic version of our feature.

  2. Open the file dessert.store.ts (src/app/data/dessert.store.ts) and see how the generic feature is called.


> Follow us on X (Twitter) > More Trainings (German) > More Trainings (English)