DEV Community

Cover image for Improve data service connectivity in Signal Stores using the withDataService Custom Store Feature
Gergely Szerovay for This is Angular

Posted on • Originally published at angularaddicts.com

Improve data service connectivity in Signal Stores using the withDataService Custom Store Feature

In this article, I'm going to show you why NgRx SignalStore's Custom Store Features is a game changer: in my example, you'll see how I used it to connect a store to a data service using my withDataService Custom Store Feature. Naturally, there are countless other use cases, please let me know in the comments if you created experiments or demo projects.

Here are some prerequisites, you should understand how Signals and SignalStore work before you dive in:

Angular Signals is a new reactivity model introduced in Angular 16. The Signals feature helps us track state changes in our applications and triggers optimized template rendering updates. If you are new to Signals, here are some highly recommended articles as a starting point:

The NgRx team and Marko Stanimirović created a signal-based state management solution, SignalStore. If you are new to Signal Stores, you should read Manfred Steyer's four-part series about the NgRx Signal Store:

I believe that the most powerful part of the NgRx SignalStore is the Custom Store Features. It provides a mechanism to extend the functionality of SignalStores in a reusable way.

To experiment with Custom Store Features, I created one named withDataService. It connects a data service to the store and tracks the state of the HTTP request. The feature was inspired by:

The full source code is available here:

To start the demo app, clone https://github.com/gergelyszerovay/store-extensions/, run pnpm install and pnpm run start, then open http://localhost:4200/article-list-signal-store-with-feature in your browser.

Demo application architecture and features

The demo application has the following features:

  • A simple menu to switch between the different article list implementations (UiArticleListComponent)
  • The ArticleListComponent_SSF is a smart component, it provides and uses the ArticleListSignalStoreWithFeature store. The component loads the article list from the server, so it has a loading and an error state. It supports pagination, and the user can also change the pagination by URL parameters, for example: http://localhost:4200/article-list-signal-store-with-feature?selectedPage=3&pageSize=2. If the user changes the URL parameters or clicks on the pagination component, the article list gets reloaded.

The ArticleListComponent_SSF component has two child UI component:

  • An article list component (UiArticleListComponent), it shows the article list with the article’s author, publication date, like count, tags and lead.
  • A pagination component below the article list (UiPaginationComponent).

The store

This is the state for the ArticleListComponent_SSF:

type ArticleListState = {
  readonly selectedPage: number,
  readonly pageSize: number,
  readonly articlesCount: number
}

export const initialArticleListState: ArticleListState = {
  selectedPage: 0,
  pageSize: 3,
  articlesCount: 0
}
Enter fullscreen mode Exit fullscreen mode

And this is the SignalStore:

export const ArticleListSignalStoreWithFeature = signalStore(
  withState(initialArticleListState),
  withEntities({ entity: type<Article>(), collection: 'article' }),
  withComputed(({ articlesCount, pageSize }) => ({
    totalPages: computed(() => Math.ceil(articlesCount() / pageSize())),
  })),
  withComputed(({ selectedPage, totalPages }) => ({
    pagination: computed(() => ({ selectedPage: selectedPage(), totalPages: totalPages() })),
  })),
  withMethods((store) => ({
    setSelectedPage(selectedPage: string | number | undefined): void {
      patchState(store, () => ({
        selectedPage: selectedPage === undefined ? initialArticleListState.selectedPage : Number(selectedPage),
      }));
    },
    setPageSize(pageSize: string | number | undefined): void {
      patchState(store, () => ({
        pageSize: pageSize === undefined ? initialArticleListState.pageSize : Number(pageSize)
      }));
    },
  })),
  withDataService({
    actionName: 'loadArticles',
    service: (store /*, rxParams: void*/) => {
      const articlesService = inject(ArticlesService);
      return articlesService.getArticles({
        limit: store.pageSize(),
        offset: store.selectedPage() * store.pageSize()
      })
      .pipe(map(response => {
        return [
          // setAllEntities doesn't work with readonly arrays, ReadonlyArray<Article> => Array<Article>
          setAllEntities(response.articles as Array<Article>, { collection: 'article' }),
          {
            articlesCount: response.articlesCount
          }
      ] }))
    }
  }),
  withDataService({
    actionName: 'toggleFavorite',
    service: (store, articleId: number) => {
      const articlesService = inject(ArticlesService);
      const article = store.articleEntityMap()[articleId]!;
      console.log('optimistic update', article);
      if (article.favorited) {
        patchState(store, setEntity(
          { ...article, favorited: false, favoritesCount: article.favoritesCount - 1 },
          { collection: 'article' })
        );
      }
      else {
        patchState(store, setEntity(
          { ...article, favorited: true, favoritesCount: article.favoritesCount + 1 },
          { collection: 'article' })
        );
      }
      // send the request to the server
      return articlesService.toggleFavorite(articleId).pipe(
      // transform the response to the store's data format
      map(response => {
        return [
          setEntity(response, { collection: 'article' })
      ] }));
    }
  })
);
Enter fullscreen mode Exit fullscreen mode

This store has the following properties and methods:

  • withStateadds the following signals: selectedPage, pageSize, articlesCount, these contain the pagination data and the total number of the articles
  • withEntities (collection: 'article') adds the articleEntityMapand articleEntityIdssignals, these contain the article entities. It adds the articleEntitiescomputed signal with the list of the articles, too.
  • withComputeds add two computed signals: totalPagesand pagination, these are the inputs of the pagination component
  • withMethods adds to method to update the selected page and the page size in the store
  • withDataService (actionName: 'loadArticles') adds the loadArticles() RxMethod, the loadArticlesRequestState signal, and the isArticleListEmpty, isArticleListFetching, isArticleListFetched and getArticleListError computed signals to the store
  • withDataService (actionName: 'toggleFavorite') adds the toggleFavorite() RxMethod, the toggleFavoriteRequestState signal, and the isToggleFavoriteEmpty, isToggleFavoriteFetching, isToggleFavoriteFetched and getToggleFavoriteError computed signals to the store

The withDataService Custom Store Feature

The withDataService feature connects a data service to the store and tracks the state of the HTTP request.

HTTP Request state

The withDataService feature represents the request state with the HttpRequestState data type:

export type HttpRequestState = HttpRequestStates | HttpRequestError;
Enter fullscreen mode Exit fullscreen mode

The HttpRequestState can be one of these request states:

export enum HttpRequestStates {
  // no request has been made
  INITIAL = 'INITIAL', 
  // a request is started, and we're waiting for the server's response
  FETCHING = 'FETCHING', 
  // a request has been successfully fetched
  FETCHED ='FETCHED' 
}
Enter fullscreen mode Exit fullscreen mode

or a HttpRequestError object, if the request fails:

export type HttpRequestError = {
  readonly errorMessage: string,
  readonly errorCode?: number
  readonly errorData?: unknown;
}
Enter fullscreen mode Exit fullscreen mode

The withDataService feature has three config options: actionName, service and the optional extractHttpErrorMessageFn.

actionName

We use this string to customize the name of the generated signals and methods. If the actionNameis loadArticles, withDataService adds the loadArticles() RxMethod to the store. In the demo app this RxMethod triggers the loading of the article list. It also adds the loadArticlesRequestState: HttpRequestState signal, and the following computed signals:

  • isArticleListInitial(): true, if the there was no request yet (initial state)
  • isArticleListFetching(): true, when the service sent the request to the server, but there has been is no response yet
  • isArticleListFetched(): true, when the service sent the request to the server, and we got a valid response
  • getArticleListError(): undefined, when the service sent the request to the server, and we got a valid response. It's a HttpRequestError, if there was an error during the request

service

This option passes a callback function to withDataService.The callback function returns an observable that, when subscribed, executes a request on the server. The function has two parameters:

  • store: it contains the SignalStore itself, and
  • rxParams: the parameter we passed to the RxMethod (loadArticles() or toggleFavorite()). In the loadArticles() method we don't use this parameter. We pass the article id to the toggleFavorite() method.

The the callback function for loadArticle is the following:

    service: (store /*, rxParams: void*/) => {
      const articlesService = inject(ArticlesService);
      return articlesService.getArticles({
        limit: store.pageSize(),
        offset: store.selectedPage() * store.pageSize()
      })
      .pipe(map(response => {
        return [
          // setAllEntities doesn't work with readonly arrays, ReadonlyArray<Article> => Array<Article>
          setAllEntities(response.articles as Array<Article>, { collection: 'article' }),
          {
            articlesCount: response.articlesCount
            }
        ] })
      );
    }
Enter fullscreen mode Exit fullscreen mode

It injects the service, creates the observable, and maps the response to a list of partial states (articlesCount: response.articlesCount) or partial state updaters (setAllEntities()) . This data structure is similar to the parameters of patchState.

The callback function for toggleFavorite also implements the optimistic update pattern. It immediately updates the state when called, and when a response is received from the server, it updates the state a second time:

   service: (store, articleId: number) => {
      // inject the service
      const articlesService = inject(ArticlesService);
      // optimistic update
      const article = store.articleEntityMap()[articleId]!;
      console.log('optimistic update', article);
      if (article.favorited) {
        patchState(store, setEntity(
          { ...article, favorited: false, favoritesCount: article.favoritesCount - 1 },
          { collection: 'article' })
        );
      }
      else {
        patchState(store, setEntity(
          { ...article, favorited: true, favoritesCount: article.favoritesCount + 1 },
          { collection: 'article' })
        );
      }
      // get the observable for sending the request to the server
      return articlesService.toggleFavorite(articleId).pipe(
      // transform the response to the store's data format
      map(response => {
        return [
          setEntity(response, { collection: 'article' })
      ] }));
    }
Enter fullscreen mode Exit fullscreen mode

extractHttpErrorMessageFn

This option is optional. We can specify a function that maps Angular's HttpErrorResponse to a HttpRequestError. If this function is not specified, withDataService uses a simple built-in version. Alternatively, you can specify a customized one that properly handles your specific backend's error responses.

The article list component

It's a smart component:

  • it provides and injects the store, and
  • has an effect to update the parameters from the URL and load the article list
@Component({
  providers: [ArticleListSignalStoreWithFeature],
  template: `
<h1 class="text-xl font-semibold my-4">SignalStore with a feature</h1>
@if (store.isLoadArticlesInitial() || store.isLoadArticlesFetching()) {
  <div>Loading...</div>
}
@if (store.isLoadArticlesFetched()) {
  <app-ui-article-list
    [articles]="store.articleEntities()"
    (toggleFavorite)="store.toggleFavorite($event)"
  />
  <app-ui-pagination
    [selectedPage]="store.pagination().selectedPage"
    [totalPages]="store.pagination().totalPages"
    (onPageSelected)="store.setSelectedPage($event); store.loadArticles();"
  />
}
@if (store.getLoadArticlesError(); as error) {
  {{ error.errorMessage }}
}`
// ...
})
export class ArticleListComponent_SSF {
  // we get these from the router, as we use withComponentInputBinding()
  selectedPage = input<string | undefined>(undefined);
  pageSize = input<string | undefined>(undefined);

  readonly store = inject(ArticleListSignalStoreWithFeature);

  constructor(
  ) {
    effect(() => {
      // 1️⃣ the effect() tracks this two signals only
      const selectedPage = this.selectedPage();
      const pageSize = this.pageSize();
      // 2️⃣ we wrap the function we want to execute on signal change 
      // with an untracked() function
      untracked(() => { // 👈
        // we don't want to track anything in this block
        this.store.setSelectedPage(selectedPage);
        this.store.setPageSize(pageSize);
        this.store.loadArticles();
      });
      console.log('router input ➡️ store (effect)', selectedPage, pageSize); 
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Summary

In this article, I demonstrated how powerful the Custom Store Features are: I showed you how we can easily connect a store to a data service using the withDataService Custom Store Feature. I hope you have found my tutorial useful!

In my next article, I’m going to explain how to unit test smart components and auto-mock SignalStores.

As always, please let me know if you have some feedback!

👨‍💻About the author

My name is Gergely Szerovay, I work as a frontend development chapter lead. Teaching (and learning) Angular is one of my passions. I consume content related to Angular on a daily basis — articles, podcasts, conference talks, you name it.

I created the Angular Addict Newsletter so that I can send you the best resources I come across each month. Whether you are a seasoned Angular Addict or a beginner, I got you covered.

Next to the newsletter, I also have a publication called Angular Addicts. It is a collection of the resources I find most informative and interesting. Let me know if you would like to be included as a writer.

Let’s learn Angular together! Subscribe here 🔥

Follow me on Substack, Medium, Dev.to, Twitter or LinkedIn to learn more about Angular!

Top comments (2)

Collapse
 
yeongcheon profile image
YeongCheon • Edited

awesome post

Collapse
 
jangelodev profile image
João Angelo

Hi Gergely Szerovay,
Excellent content, very useful.
Thanks for sharing.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.