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_SSF
is a smart component, it provides and uses theArticleListSignalStoreWithFeature
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
}
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:
-
withState
adds the following signals:selectedPage
,pageSize
,articlesCount,
these contain the pagination data and the total number of the articles -
withEntities
(collection: 'article') adds thearticleEntityMap
andarticleEntityIds
signals, these contain the article entities. It adds thearticleEntities
computed signal with the list of the articles, too. -
withComputed
s add two computed signals:totalPages
andpagination,
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 theloadArticles()
RxMethod, theloadArticlesRequestState
signal, and theisArticleListEmpty, isArticleListFetching, isArticleListFetched
andgetArticleListError
computed signals to the store -
withDataService
(actionName: 'toggleFavorite') adds thetoggleFavorite()
RxMethod, thetoggleFavoriteRequestState
signal, and theisToggleFavoriteEmpty, isToggleFavoriteFetching, isToggleFavoriteFetched
andgetToggleFavoriteError
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;
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 actionName
is 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)
awesome post
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.