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_SSFfunctions as a smart component, providing and consuming theArticleListSignalStoreWithFeaturestore. 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
}
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' })
] }));
}
})
);
The store exposes the following properties and methods:
withStateintroduces theselectedPage,pageSize, andarticlesCountsignals, which hold pagination information and the total article count.withEntities(using the 'article' collection) adds thearticleEntityMapandarticleEntityIdssignals for storing article entities. It also introduces thearticleEntitiescomputed signal that provides the article list.withComputeddefines two computed signals—totalPagesandpagination—which serve as inputs for the pagination component.withMethodsadds two methods for updating the selected page and page size within the store.withDataServicewithactionName: 'loadArticles'introduces theloadArticles()RxMethod, theloadArticlesRequestStatesignal, and computed signals includingisArticleListEmpty,isArticleListFetching,isArticleListFetched, andgetArticleListError.withDataServicewithactionName: 'toggleFavorite'adds thetoggleFavorite()RxMethod, thetoggleFavoriteRequestStatesignal, and computed signals such asisToggleFavoriteEmpty,isToggleFavoriteFetching,isToggleFavoriteFetched, andgetToggleFavoriteError.
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;
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'
}
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;
}
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 aHttpRequestErrorwhen 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 — eitherloadArticles()ortoggleFavorite(). For theloadArticles()method this argument remains unused, while thetoggleFavorite()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
}
] })
);
}
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' })
] }));
}
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);
});
}
}
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!
