DEV Community

Cover image for From NgRx ComponentStore to SignalStore: the key takeaways from my demo project
Gergely Szerovay for This is Angular

Posted on • Edited on • Originally published at angularaddicts.com

From NgRx ComponentStore to SignalStore: the key takeaways from my demo project

I believe that Signals in Angular will fundamentally change the way we create Angular applications. This article is the first part of a series that aims to show you the potential of this new feature, and at the same time help you prepare for this change effectively: while Signals is in developer preview and the NgRx signal-based store is just a prototype, you can start creating and refactoring your components in a way that will make migration really smooth for you. In this first part, I show you how I used a demo application to showcase the differences between the ComponentStore and the new signal-based model. In the next part of the series, I’m going to offer you some guidelines on how to navigate this change. So first let me introduce Signals and the NgRx SignalStore.

Angular Signals is a new reactivity model in Angular 16. Signals help us track state changes in our applications and trigger optimized template rendering updates. If you are new to Signals, here are some highly recommended articles:

The NgRx team and Marko Stanimirović opened a new RFC (Request for Comments) for a signal-based state management solution, SignalStore. It has a similar approach to @ngrx/component-store. The initial prototype with the API documentation is available in the NgRx SignalStore playground repo.

As I mentioned, I'm confident that Signals will transform the way we develop Angular applications. To gain more knowledge of this new feature and its future impacts, I’ve created two versions of an “article list” component. I’ve built a ComponentStore-based one first, then migrated it to a SignalStore-based one. In this article, I explain the implementation steps and the main differences I found, so that you can better understand how SignalStores actually work.

The full source code is available here:

https://github.com/gergelyszerovay/component-store-to-signal-store

The application uses the styling and the public hosted backend from the RealWorld project.

Screenshot

The application has the following features:

  • A simple menu to switch between the ComponentStore- and a SignalStore-based article list
  • Two article lists, one of them is ComponentStore-based, the other one is SignalStore-based. They show the article’s author, publication date, like count, tags and lead. They load the article list from the server, so they have a loading and an error state
  • A pagination component below each article list. The user can also change the pagination by URL parameters, for example: http://localhost:4200/article-list-component-store?selectedPage=3&pageSize=2. If the user changes the URL parameters or clicks on the pagination component, the article list gets reloaded.

Application architecture

I use Angular v16 with standalone components. As Signals doesn’t work in zoneless applications yet, I use the OnPush change detection strategy with async pipes.

The app bootstraps an AppComponent with a router-outlet and two menu items for the two versions of the article list:

  • The ArticleListComponent_CS is the ComponentStore-based version of the article list. It’s connected with the ArticleListComponentStore.
  • The ArticleListComponent_SS is the SignalStore-based version of the article list. It’s connected with the ArticleListSignalStore.

Both “article list“ implementations use a component-level store and rely on the following UI components:

  • The UiArticleListComponent renders the list of the articles (UiArticleLisItemComponent)
  • The UiPaginationComponent handles the pagination

The directory structure is the following:

src/
|-- app/
|   |-- article-list-ngrx-component-store/ => ArticleListComponent_CS
|   |-- article-list-ngrx-signal-store/ => ArticleListComponent_SS
|   |-- models/
|   |-- services/
|   |-- ui-components/ 
|   |-- app.component.ts
|   |-- app.routes.ts
|-- libs/signal-store/
Enter fullscreen mode Exit fullscreen mode

The article list components

The class codes of the two article list components are almost identical:

  • we inject the router and the store,
  • we update the pagination parameters in the store after the component was created. We also update the parameters if the parameters on the URL change

The only difference between them is the class of the injected store: ArticleListComponentStore and ArticleListSignalStore:

export class ArticleListComponent_CS {
  readonly store = inject(ArticleListComponentStore);
  readonly route = inject(ActivatedRoute);

  constructor(
  ) {
    this.route.queryParams.pipe(takeUntilDestroyed()).subscribe(
      routeParams => {
      this.store.setPaginationSettings(routeParams);
      this.store.loadArticles();
    });
  }
}
Enter fullscreen mode Exit fullscreen mode
export class ArticleListComponent_SS {
  readonly store = inject(ArticleListSignalStore);
  readonly route = inject(ActivatedRoute);

  constructor(
  ) {
    this.route.queryParams.pipe(takeUntilDestroyed()).subscribe(
      routeParams => {
      this.store.setPaginationSettings(routeParams);
      this.store.loadArticles();
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The templates of the components are similar, too. The fundamental difference is the way we read the data from the stores:

  • we use async pipes to read from the selectors of the ComponentStore, and
  • we simply get the value of the signals in the SignalStore
@Component({
  selector: app-article-list-cs,
  // ...
  providers: [ArticleListComponentStore],
  template: `
<ng-container *ngIf="(store.httpRequestState$ | async) === ‘FETCHING’">
  Loading...
</ng-container>
<ng-container *ngIf="store.httpRequestState$ | async | httpRequestStateErrorPipe as errorMessage">
  {{ errorMessage }}
</ng-container>
<ng-container *ngIf="(store.httpRequestState$ | async) === ‘FETCHED’">
  <ng-container *ngIf="store.articles$ | async as articles">
    <app-ui-article-list [articles]="articles"/>
  </ng-container>
  <ng-container *ngIf="store.pagination$ | async as pagination">
    <app-ui-pagination
      [selectedPage]="pagination.selectedPage"
      [totalPages]="pagination.totalPages"
      (onPageSelected)="store.setSelectedPage($event); store.loadArticles();" />
  </ng-container>
</ng-container>
  `
})
Enter fullscreen mode Exit fullscreen mode
@Component({
  selector: app-article-list-ss,
  // ...
  providers: [ArticleListSignalStore],
  template: `
<ng-container *ngIf="store.httpRequestState() === ‘FETCHING’">
  Loading...
</ng-container>
<ng-container *ngIf="store.httpRequestState() | httpRequestStateErrorPipe as errorMessage">
  {{ errorMessage }}
</ng-container>
<ng-container *ngIf="store.httpRequestState() === ‘FETCHED’">
  <ng-container *ngIf="store.articles() as articles">
    <app-ui-article-list [articles]="articles"/>
  </ng-container>
  <ng-container *ngIf="store.pagination() as pagination">
    <app-ui-pagination
      [selectedPage]="pagination.selectedPage()"
      [totalPages]="pagination.totalPages()"
      (onPageSelected)="store.setSelectedPage($event); store.loadArticles();" />
  </ng-container>
</ng-container>
  `
})
Enter fullscreen mode Exit fullscreen mode

State

I apply the same immutable data structure for storing the state in both stores (HttpRequestState and Articles are also immutable types):

export type ArticleListState = {
  readonly selectedPage: number,
  readonly pageSize: number,

  readonly httpRequestState: HttpRequestState,

  readonly articles: Articles,
  readonly articlesCount: number,
}
Enter fullscreen mode Exit fullscreen mode

The selectedPage property specifies the currently visible page, the pageSize property defines how many articles are visible. The user can change these values by using the pagination component or by applying URL parameters.

The httpRequestState property contains the request state of the article list:

export type HttpRequestState = DeepReadonly<
  'EMPTY' | 'FETCHING' | 'FETCHED' |
  { errorMessage: string }
  >;
Enter fullscreen mode Exit fullscreen mode

Initially, its value is EMPTY. We change it to FETCHING right before we send a request to the server. When the server’s response arrives, we set its value to FETCHED. If the server sends an error response or there is an error during the request, we set the request state to an { errorMessage: string } object with the error message.

The server’s response contains the total number of articles and the articles themselves, we store these in the articlesCount and articles properties.

After we create the article list components, their stores have an initial state:

export const initialArticleListState: ArticleListState = {
  selectedPage: 0,
  pageSize: 3,

  httpRequestState: EMPTY,

  articles: [],
  articlesCount: 0
}
Enter fullscreen mode Exit fullscreen mode

Stores

I extend the ArticleListComponentStore from a ComponentStore:

@Injectable()
export class ArticleListComponentStore extends ComponentStore<ArticleListState> {
  readonly selectedPage$: Observable<number> = /* ... */;
  readonly pageSize$: Observable<number> = /* ... */;
  readonly httpRequestState$: Observable<HttpRequestState> = /* ... */;
  readonly articles$: Observable<DeepReadonly<Articles>> = /* ... */;
  readonly articlesCount$: Observable<number> = /* ... */;

  readonly totalPages$: Observable<number> = /* ... */;

  readonly pagination$: Observable<{ selectedPage: number, totalPages: number }> = /* ... */;

  readonly articlesService = inject(ArticlesService);

  constructor(
  ) {
    super(initialArticleListState);
  }

  setPaginationSettings = this.updater(
    (state, s: RouteParamsPaginatonState) => /* ... */);

  readonly loadArticles = this.effect<void>(/* ... */);

  setRequestStateLoading = this.updater(
    (state) => /* ... */);

  setRequestStateSuccess = this.updater(
    (state, params: ArticlesResponseType) => /* ... */);

  setRequestStateError = this.updater(
    (state, error: string): => /* ... */);

  setSelectedPage = this.updater(
    (state, selectedPage: number) => /* ... */);
}
Enter fullscreen mode Exit fullscreen mode

I create the ArticleListSignalStore with the signalStore() function. It accepts a sequence of store features, I’ll explain these in more detail:

export const ArticleListSignalStore = signalStore(
  { debugId: ArticleListSignalStore },
  withState<ArticleListState>(initialArticleListState),
  withComputed(({ articlesCount, pageSize }) => ({ /* ... */ })),
  withComputed(({ selectedPage, totalPages }) => ({ /* ... */ })),
  withUpdaters(({ update }) => ({
    setPaginationSettings: (s: RouteParamsPaginatonState) =>  /* ... */,
    setRequestStateLoading: () => /* ... */ ,
    setRequestStateSuccess: => /* ... */ ,
    setRequestStateError: (error: string) => /* ... */ ,
    setSelectedPage: (selectedPage: number) =>  /* ... */,
  withEffects(
    ( {
      selectedPage, pageSize,
      setRequestStateLoading, setRequestStateSuccess, setRequestStateError
      },
    ) => {
      const articlesService = inject(ArticlesService)
      // ...
    }
  )
);
Enter fullscreen mode Exit fullscreen mode

Selectors

ArticleListComponentStore stores the state in its store$ subject. This subject emits a value on every state change. To observe the modifications of the state’s properties individually, we make a separate selector for each of these properties:

 readonly selectedPage$: Observable<number> = 
    this.select(state => state.selectedPage);
  readonly pageSize$: Observable<number> = 
    this.select(state => state.pageSize);
  readonly httpRequestState$: Observable<HttpRequestState> = 
    this.select(state => state.httpRequestState);
  readonly articles$: Observable<DeepReadonly<Articles>> = 
    this.select(state => state.articles);
  readonly articlesCount$: Observable<number> = 
    this.select(state => state.articlesCount);
Enter fullscreen mode Exit fullscreen mode

SignalStore automatically creates a separate signal for all the root properties of the state. We refer to these as partial states. We can access these partial states by:

  • ArticleListSignalStore.selectedPage()
  • ArticleListSignalStore.pageSize()
  • ArticleListSignalStore.httpRequestState()
  • ArticleListSignalStore.articles() and
  • ArticleListSignalStore.articlesCount()

I create an additional combined selector in ArticleListComponentStore to calculate the number of the pages:

 readonly totalPages$: Observable<number> = this.select(
    this.articlesCount$, this.pageSize$,
    (articlesCount, pageSize) => Math.ceil(articlesCount / pageSize));
Enter fullscreen mode Exit fullscreen mode

To do the same in the ArticleListSignalStore, I use the withComputed() function. I provide the articlesCount and pageSize signals as a parameter to the function, and calculate the total number of the pages:

 withComputed(({ articlesCount, pageSize }) => ({
    totalPages: computed(() => Math.ceil(articlesCount() / pageSize())),
  })),
Enter fullscreen mode Exit fullscreen mode

We also need to add a “view model” selector to the pagination component. This is the code for the selector in ArticleListComponentStore:

 readonly pagination$: Observable<{ selectedPage: number, totalPages: number }> = this.select(
    this.selectedPage$,
    this.totalPages$,
    (selectedPage, totalPages) => ({ selectedPage, totalPages })
  );
Enter fullscreen mode Exit fullscreen mode

And this is the same selector in the ArticleListSignalStore, too:

 withComputed(({ selectedPage, totalPages }) => ({
    pagination: computed(() => ({ selectedPage, totalPages })),
  })),
Enter fullscreen mode Exit fullscreen mode

Updaters

Inside the updaters of a ComponentStore, we always create a new immutable state object with the updated values and return it. The returned state object contains all properties from the state, both the updated and the unmodified ones.

For example, this is how we handle a server response:

 setRequestStateSuccess = this.updater((state, params: ArticlesResponseType): ArticleListState => {
    return {
      ...state,
      httpRequestState: FETCHED,
      articles: params.articles,
      articlesCount: params.articlesCount
    }
  });
Enter fullscreen mode Exit fullscreen mode

The params parameter contains the articles and articlesCount values from the server’s response:

export type ArticlesResponseType = {
  articles: Articles,
  articlesCount: number
}
Enter fullscreen mode Exit fullscreen mode

In the ArticleListSignalStore, we create the updaters with the withUpdaters() function. In these updaters, we create a new immutable object from the updated properties only, so there is no ...state here. The SignalStore updates the partial states with using these returned property values:

 withUpdaters(({ update }) => ({
    setPaginationSettings: (s: RouteParamsPaginatonState) => update(() => ({
    // ...
    setRequestStateSuccess: (params: ArticlesResponseType) => update(() => ({
      httpRequestState: FETCHED,
      articles: params.articles,
      articlesCount: params.articlesCount
    }))
    // ...
  }))
Enter fullscreen mode Exit fullscreen mode

Effects

The stores have a single effect that fetches the article list from the server. This is the effect of ArticleListComponentStore:

 readonly loadArticles = this.effect<void>((trigger$: Observable<void>) => {
    return trigger$.pipe(
      withLatestFrom(this.selectedPage$, this.pageSize$),
      tap(() => this.setRequestStateLoading()),
      switchMap(([, selectedPage, pageSize]) => {
        return this.articlesService.getArticles({
          limit: pageSize,
          offset: selectedPage * pageSize
        }).pipe(
          tapResponse(
            (response) => {
              this.setRequestStateSuccess(response);
            },
            (errorResponse: HttpErrorResponse) => {
              this.setRequestStateError(Request error);
            }
          ),
        );
      }),
    );
  });
Enter fullscreen mode Exit fullscreen mode

In a SignalStore, we implement effects with the withEffects() function. SignalStores support two different effect types: RxJs-based effects and Promise-based effects. The RxJs-based effects look very similar to the effects we use in a ComponentStore:

withEffects(
    ( {
      selectedPage, pageSize,
      setRequestStateLoading, setRequestStateSuccess, setRequestStateError
      },
    ) => {
      const articlesService = inject(ArticlesService)
      return {
        loadArticles: rxEffect<void>(
          pipe(
            tap(() => setRequestStateLoading()),
            switchMap(() => articlesService.getArticles({
              limit: pageSize(),
              offset: selectedPage() * pageSize()
            })),
            tapResponse(
              (response) => {
                setRequestStateSuccess(response);
              },
              (errorResponse: HttpErrorResponse) => {
                setRequestStateError(Request error);
              }
            )
          )
        )
      }
    }
  )
Enter fullscreen mode Exit fullscreen mode

Promise-based effects are useful when a Promise has sufficient functionality and we don’t need the power of RxJs. In case of fetching data from the server, it has a drawback: it doesn’t support a cancellation logic:

withEffects(
    ( {
      selectedPage, pageSize,
      setRequestStateLoading, setRequestStateSuccess, setRequestStateError
      },
    ) => {
      const articlesService = inject(ArticlesService)
      return {
        async loadArticles() {
          setRequestStateLoading();
          try {
            const response = await lastValueFrom(articlesService.getArticles({
              limit: pageSize(),
              offset: selectedPage() * pageSize()
            }));
            setRequestStateSuccess(response);
          }
          catch(e) {
            setRequestStateError(Request error);
          }
        }
      }
    }
  )
Enter fullscreen mode Exit fullscreen mode

Summary

To sum up, the key differences between the ComponentStore and the SignalStore are these:

  • ComponentStore has its state in the state$ subject. SignalStore has a separate signal for all the root properties of the state (partial states)
  • In a SignalStore, we don’t need selectors to access the root level properties of the state. It stores these in separate signals, so these are directly accessible.
  • Both ComponentStore and SignalStore supports RxJs-based effects, additionally SignalStore supports Promise-based effects, too.

Although the current SignalStore implementation is just a prototype, and there might be API changes in the future, I really enjoy working with it. Its API is flexible and easy to understand, as it follows the basic concepts of the ComponentStore, but in a more advanced way.

To make it easier to debug state changes, updaters and effects, I patched the original SignalStore code with some debug code from my ngx-ngrx-component-store-debug-tools project.

The main question I have now is how to create the ComponentStores in a way that when the production-ready SignalStore is released, it’ll allow for an easily manageable migration process.

In the next part of my article series, I will define some guidelines that will help us reach this goal. Additionally, I’m going to examine some complex scenarios and compare these two approaches, for example how HTTP request cancellation works with SignalStore and ComponentStore.

Thanks for reading, I hope you found my article helpful, 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 — you guessed it — 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 Medium, Twitter or LinkedIn to learn more about Angular!

Top comments (0)