@ngrx-traits/signals remote pagination

Gabriel Guerrero
6 min readApr 30, 2024

This is the second part of a series of articles about a new library, @ngrx-traits/signals, a set of @ngrx/signals custom store features that allow you to do common things like pagination, filtering, sorting, and more. If you missed the first article, you can read it here.

As with the previous article, if you want to play with the final app we will build, you can do so in this stackblitz

This time around, we are going to be talking about remote pagination and filtering plus the use of the collection param in withEntities* store features; the latter is the simplest, so let’s start with that

Collection Param for withEntities*

Let’s add the collection param to the example of the previous article, and see how it changes, first the store:

const entity = type<Product>();
const collection = 'products'; // 👈 collection name
export const ProductsLocalStore = signalStore(
withEntities({ entity, collection }), // 👈 added collection
withCallStatus({ collection, initialValue: 'loading' }), // 👈 added collection
withEntitiesLocalPagination({ entity, collection, pageSize: 5 }), // 👈 added collection
withEntitiesLocalFilter({
entity,
collection, // 👈 added collection
defaultFilter: {
search: '',
},
filterFn: (entity, filter) =>
!filter?.search ||
entity?.name.toLowerCase().includes(filter?.search.toLowerCase()),
}),
withEntitiesSingleSelection({ entity, collection }), // 👈 added collection
// 👇 Notice bellow the generated signals and methods changed
// they now use the collection name
withHooks(
({ setProductsLoaded, setProductsError, ...store }) => ({
onInit: async () => {
const productService = inject(ProductService);
try {
const res = await lastValueFrom(productService.getProducts());
patchState(store, setAllEntities(res.resultList, { collection }));
setProductsLoaded();
} catch (e) {
setProductsError(e);
}
},
}),
),
// ... code omitted for brevity

The collection param will transform the generated names to use the collection's name, allowing you to repeat the withEntities* functions as long as you use different collection names.

This will affect our component, let's see how it changed:

 <form>
<mat-form-field>
<mat-label>Search</mat-label>
<!-- 👇 here we used to have store.entitiesFilter
and store.filterEntities-->
<input
type="text"
matInput
[ngModel]="store.productsFilter()?.search"
name="search"
(ngModelChange)="store.filterProductsEntities({ filter: { search: $event } })"
/>
</mat-form-field>
</form>
<!-- 👇 used to be store.isLoading -->
@if (store.isProductsLoading()) {
<mat-spinner />
} @else {
<mat-list>
<!-- 👇 used to be store.store.entitiesCurrentPage() -->
@for (
product of store.productsCurrentPage().entities;
track product.id
) {
<mat-list-item
[class.selected]="store.productsEntitySelected() === product"
(click)="select(product)"
>{{ product.name }}</mat-list-item>
}
</mat-list>
<!-- 👇 same here, use to be store.store.entitiesCurrentPage() -->
<mat-paginator
[length]="store.productsCurrentPage().total"
[pageSize]="store.productsCurrentPage().pageSize"
[pageIndex]="store.productsCurrentPage().pageIndex"
(page)="this.store.loadProductsPage($event)"
/>
@if (store.isLoadProductDetailLoading()) {
<mat-spinner />
} @else {
<product-detail [product]="store.loadProductDetailResult()" />
}
}
`,
})
export class SignalProductListPaginatedPageContainerComponent {
store = inject(ProductsLocalStore);

select({ id }: Product) {
// 👇 used to be store.selectEntity
this.store.selectProductsEntity({ id });
this.store.loadProductDetail({ id });
}
}

Remote Pagination and Filtering

To use remote pagination and filtering we need to make a few changes, let me show you the code changes first, and I’ll explain them later

const entity = type<Product>();
const collection = 'products';
export const ProductsLocalStore = signalStore(
withEntities({ entity, collection }),
withCallStatus({ collection, initialValue: 'loading' }),
// we replace the local for the remote version of the functions
withEntitiesRemotePagination({
entity,
collection,
pageSize: 5,
// 👇 new param, numbers of pages to cache, default is 3
pagesToCache: 4,
}),
withEntitiesRemoteFilter({
entity,
collection,
defaultFilter: {
search: '',
},
// we removed the filterFn, filtering is done on the server
}),
withEntitiesSingleSelection({ entity, collection }),
withHooks(
({
productsFilter, // 👈 stored filter
productsPagedRequest, // 👈 page info to request data
isProductsLoading,
setProductsLoaded,
setProductsError,
setProductsPagedResult, // 👈 store the result
...store
}) => ({
onInit: () => {
effect(() => {
// 👆 we use an effect to load the products because
// we want this to get executed each time the status
// is set to loading
if (isProductsLoading()) {
const productService = inject(ProductService);
untracked(async () => {
try {
const res = await lastValueFrom(
productService.getProducts({
// 👇 use the last stored values for search, take and skip
search: productsFilter().search,
take: productsPagedRequest().size,
skip: productsPagedRequest().startIndex,
}),
);
// 👇 we use setProductsPagedResult instead of setAllEntities, needed
// to store the entities and update calculations like number of pages etc
setProductsPagedResult({
entities: res.resultList,
total: res.total,
});
setProductsLoaded();
} catch (e) {
setProductsError(e);
}
});
}
});
},
}),
),
);

Notice that we now use withEntitiesRemotePagination and withEntitiesRemoteFilter instead of their local versions; although they generate the same name for signals and methods (so no changes on your component), they work differently from their local version. When filterProductsEntities({ filter:{ search: $event } }), and loadProductsPage({pageIndex: number}), are executed in the local versions, both first save to the store the params (the new filter value and new pageIndex respectively), and then do the changes in memory to either filter the entities or load the entities for a different page from the cache.

The remote version will store the params, but instead of doing any in-memory operation, they will call setProductsLoading(), to signal the backend needs to fetch entities using the stored params; that is why we changed our onInit to have an effect that gets executed each time the status is loading. The fetch of entities will get triggered at the beginning because we set the initial state of status to loading, and later, every time the filter or page changes ( if the page is not in cache).

Notice that when we call the backend, it is now using the signals from the store to read the latest value of the filter and page request to be loaded. The productsFilter() returns the last stored filter, the productsPagedRequest() returns page, size and startIndex of the new page to be requested. For example, if you need to load page 2 (where 0 is first page), and you configured a pageSize of 10, and pagesToCache is 3, the value of productsPagedRequest() will be {page:2 , startIndex: 20, size : 30}.

Another important change is that instead of calling patchState and setAllEntities, I called setProductsPagedResult({entities, total}). The withEntitiesRemotePagination provides this method, and it requires a total besides the entities array. This is necessary so that the cache calculations, number of pages, etc., are updated.

Preload the next pages

With withEntitiesRemotePagination when the user sees a page, it will check if the following page is not cached, if so, it will make a call to the backend to cache it. This means that when the user changes the page, it will look like all pages are in the cache unless the user goes too fast switching the pages of course, but usually, users tend to spend at least a second reading a page, which is normally enough time to load the next set of pages. But for this to work, we need a small change in the template:

<!-- 👇 use to be store.isProductsLoading() -->
@if (store.productsCurrentPage().isLoading) {
<mat-spinner />
} @else {
<mat-list>
@for (product of store.productsCurrentPage().entities; track product.id) {
<mat-list-item
[class.selected]="store.productsSelectedEntity() === product"
(click)="select(product)"
>{{ product.name }}</mat-list-item>
}
</mat-list>
}

Use productsCurrentPage().isLoading instead of isProductsLoading(), the latter will become true on any request made to the backend, including the background one loading the following page if not cached, productsCurrentPage().isLoading will only return true if the current page is loading, which allows you not to show the spinner when the next pages are being loaded in the background.

And that is it. We have remote pagination and filter working; there is also a local and remote sorting store feature that is used similarly, so I omitted it to keep the example simple, but you can see examples of it in the examples section of the project. You can find more about withEntitiesRemotePagination and withEntitiesRemoteFilter in the linked API docs.

But wait there is one more thing. That withHooks method has become quite big. Is there a way to improve it? well yes …

withEntitiesLoadingCall

This function does exactly what we have been doing in our handwritten withHook, it’s similar to withCall but specialised for entities. It will run every time the isProductsLoading() is true, handle the status changes and store the entities if successful or errors if it fails, so you only need to pass the function that calls the backend. Our final store will look like this:

const entity = type<Product>();
const collection = 'products';
export const ProductsLocalStore = signalStore(
withEntities({ entity, collection }),
withCallStatus({ collection, initialValue: 'loading' }),
withEntitiesRemotePagination({
entity,
collection,
pageSize: 5,
pagesToCache: 4,
}),
withEntitiesRemoteFilter({
entity,
collection,
defaultFilter: {
search: '',
},
}),
withEntitiesSingleSelection({ entity, collection }),
// 👇 this replaces the withHooks
withEntitiesLoadingCall({
collection,
fetchEntities: ({ productsFilter, productsPagedRequest }) => {
// here we are using rxjs but you can use async/await style as well
return inject(ProductService)
.getProducts({
search: productsFilter().search,
take: productsPagedRequest().size,
skip: productsPagedRequest().startIndex,
})
.pipe(map((res) => ({ entities: res.resultList, total: res.total })));
},
}),
withCalls(() => ({
loadProductDetail: ({ id }: { id: string }) =>
inject(ProductService).getProductDetail(id),
})),
);

Conclusion

I hope you liked this article and @ngrx-traits/signals; there are more custom store features I did not mention, I will probably write other articles about them, but for now, please check the API docs here or GitHub page to find more about them. Happy coding :).

--

--