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:
- Official Angular Signals documentation
- “Signals in Angular – How to Write More Reactive Code” by Deborah Kurata
- “Angular & signals. Everything you need to know” by Robin Goetz
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:
- The new NGRX Signal Store for Angular: 3+n Flavors
- Smarter, Not Harder: Simplifying your Application With NGRX Signal Store and Custom Features
- NGRX Signal Store Deep Dive: Flexible and Type-Safe Custom Extensions
- The NGRX Signal Store and Your Architecture
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 custom features described in Manfred Steyer's NGRX Signal Store Deep Dive: Flexible and Type-Safe Custom Extensions article and the referenced Source Code
- the custom features in Marko Stanimirović's ngrx-signal-store-playground repo, and
- the source code of the @ngrx/signals/entities package
The full source code is available here:
- Demo app: https://github.com/gergelyszerovay/store-extensions/tree/main/projects/demo/src/app/article-list-ngrx-signal-store-feature
- 
withDataService:https://github.com/gergelyszerovay/store-extensions/tree/main/projects/signal-store-data-service-feature
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_SSFis a smart component, it provides and uses theArticleListSignalStoreWithFeaturestore. 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
}
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' })
      ] }));
    }
  })
);
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 thearticleEntityMapandarticleEntityIdssignals, these contain the article entities. It adds thearticleEntitiescomputed signal with the list of the articles, too.
- 
withComputeds add two computed signals:totalPagesandpagination,these are the inputs of the pagination component
- 
withMethodsadds to method to update the selected page and the page size in the store
- 
withDataService(actionName: 'loadArticles') adds theloadArticles()RxMethod, theloadArticlesRequestStatesignal, and theisArticleListEmpty, isArticleListFetching, isArticleListFetchedandgetArticleListErrorcomputed signals to the store
- 
withDataService(actionName: 'toggleFavorite') adds thetoggleFavorite()RxMethod, thetoggleFavoriteRequestStatesignal, and theisToggleFavoriteEmpty, isToggleFavoriteFetching, isToggleFavoriteFetchedandgetToggleFavoriteErrorcomputed 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;
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' 
}
or a HttpRequestError object, if the request fails:
export type HttpRequestError = {
  readonly errorMessage: string,
  readonly errorCode?: number
  readonly errorData?: unknown;
}
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()ortoggleFavorite()). In theloadArticles()method we don't use this parameter. We pass the article id to thetoggleFavorite()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
            }
        ] })
      );
    }
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' })
      ] }));
    }
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); 
    });
  }
}
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)
Hi Gergely Szerovay,
Excellent content, very useful.
Thanks for sharing.
awesome post
Some comments may only be visible to logged-in visitors. Sign in to view all comments.