Demo application architecture and features

The demo application showcases several capabilities:

  • A navigation menu that lets users switch between different article list implementations (UiArticleListComponent)
  • The ArticleListComponent_SSF functions as a smart component, providing and consuming the ArticleListSignalStoreWithFeature store. It fetches the article list from the server and manages loading and error states. Pagination is supported, and users can modify pagination through URL parameters—for instance, http://localhost:4200/article-list-signal-store-with-feature?selectedPage=3&pageSize=2. Any change to the URL parameters or pagination component triggers a reload of the article list.

Two child UI components belong to ArticleListComponent_SSF:

  • An article list component (UiArticleListComponent) that displays articles with their authors, publication dates, like counts, tags, and leads.
  • A pagination component (UiPaginationComponent) positioned below the article list.

The store

Here is the state definition for 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

This is the corresponding SignalStore implementation:

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

The store exposes the following properties and methods:

  • withState introduces the selectedPage, pageSize, and articlesCount signals, which hold pagination information and the total article count.
  • withEntities (using the 'article' collection) adds the articleEntityMap and articleEntityIds signals for storing article entities. It also introduces the articleEntities computed signal that provides the article list.
  • withComputed defines two computed signals—totalPages and pagination—which serve as inputs for the pagination component.
  • withMethods adds two methods for updating the selected page and page size within the store.
  • withDataService with actionName: 'loadArticles' introduces the loadArticles() RxMethod, the loadArticlesRequestState signal, and computed signals including isArticleListEmpty, isArticleListFetching, isArticleListFetched, and getArticleListError.
  • withDataService with actionName: 'toggleFavorite' adds the toggleFavorite() RxMethod, the toggleFavoriteRequestState signal, and computed signals such as isToggleFavoriteEmpty, isToggleFavoriteFetching, isToggleFavoriteFetched, and getToggleFavoriteError.

Building a withDataService Custom Store Feature

The withDataService feature establishes a link between a data service and the store while keeping track of the HTTP request's status.

Understanding Request State Tracking

Request states are represented through the HttpRequestState data type when using withDataService:

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

The HttpRequestState can exist in several distinct 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

Alternatively, when a request encounters an error, the state returns a HttpRequestError object:

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

Configuration for withDataService includes three settings: actionName, service, and the optional extractHttpErrorMessageFn.

Configuring actionName

This string determines the naming convention for generated signals and methods. When actionName is set to loadArticles, the store receives a loadArticles() RxMethod that triggers article list loading in the demo application. Additionally, a loadArticlesRequestState: HttpRequestState signal and these computed signals are produced:

  • isArticleListInitial(): returns true when no request has been made yet (initial state)
  • isArticleListFetching(): returns true while the service has dispatched the request but no response has been received
  • isArticleListFetched(): returns true once the service sent the request and a valid response was received
  • getArticleListError(): returns undefined when the request succeeded with a valid response; holds a HttpRequestError when the request encountered a failure

Defining the service callback

This configuration option supplies a callback function to withDataService. The callback returns an observable that, when subscribed, triggers a server request. The callback accepts two parameters:

  • store: provides access to the SignalStore instance, and
  • rxParams: the arguments provided to the RxMethod — either loadArticles() or toggleFavorite(). For the loadArticles() method this argument remains unused, while the toggleFavorite() method receives an article id.

The callback configuration for loadArticle looks like this:

    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

Inside this callback, the service gets injected, the observable is created, and the response is mapped into partial states (like articlesCount: response.articlesCount) or state updaters (such as setAllEntities()). This structure mirrors what patchState accepts.

The callback for toggleFavorite follows the optimistic update pattern as well. State updates happen instantly when the method is invoked, then a second state update occurs once the server responds:

   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

Handling error extraction with extractHttpErrorMessageFn

This setting is not required. It allows specification of a custom function that converts Angular's HttpErrorResponse into a HttpRequestError. Without an explicit function, withDataService falls back to a straightforward default implementation. A custom function becomes useful when your backend provides a distinctive error format that needs tailored processing.

The Article List Smart Component

This component acts as a smart component by:

  • providing and injecting the store instance, and
  • using an effect to sync parameter changes from the URL and trigger article list loading
@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

Wrap-up

This tutorial showcased the flexibility of Custom Store Features. It demonstrated how to seamlessly integrate a store with a data service using the withDataService Custom Store Feature. I trust this walkthrough has been helpful!

The upcoming article will cover unit testing techniques for smart components and how to auto-mock SignalStores.

As always, feel free to share your thoughts and feedback!

👨‍💻 About the Author

I'm Gergely Szerovay, a frontend development chapter lead. Teaching and learning Angular are among my greatest passions. I stay up to date with Angular content daily — whether it's articles, podcasts, or conference talks.

The Angular Addict Newsletter came from this passion, allowing me to share the most valuable resources I encounter each month. Whether you're just starting or have years of Angular experience, there's something for everyone.

I also run the Angular Addicts publication, a curated collection of resources I find most valuable. Writer contributions are always welcome.

Let's deepen our Angular knowledge together! Subscribe today 🔥

Connect with me on Substack, Medium, Dev.to, Twitter, or LinkedIn for more Angular content!