In the last article we created a component store using NgRx to handle the pagination of todo items
Unfortunately, this approach is very specific to our domain and it is now time for us to look for a reusable solution that we can slap on another entity that we would like to paginate the same way
Table of content
Creating our new store
Let's begin by creating a new paginated-items.component-store.ts
file where our
pagination logic will take place.
The state
Before defining our store, we will first have to focus on its state
Here is our state in its current form:
export interface AppState {
todoItems: TodoItem[];
offset: number;
pageSize: number;
}
We can divide its properties into two main categories:
- Everything related to the pagination (
offset
,pageSize
) - Everything related to the items themselves (only
todoItems
in our case)
From this, we can extract a subsequent interface, which has the responsibility of wrapping the parameters related to the pagination:
export interface PaginationDetails {
offset: number;
pageSize: number;
}
And another one which will wrap the content of the page:
export interface PageContent {
todoItems: TodoItem[];
}
Using these two interfaces, we now have a state that is a bit more explicit:
export interface PaginatedItemsState {
paginationDetails: PaginationDetails;
pageContent: PageContent;
}
Great, let's move on!
The store itself
Setting the foundations
This store will be defined as an abstract class so that each subsequent store can add its own logic to is.
Using the previously defined state, we can write this:
@Injectable()
// π Beware not to forget the `abstract` here
export abstract class PaginatedItemsComponentStore
extends ComponentStore<PaginatedItemsState> { }
Creating selectors
Having state management is great, being able to access its content is better: let's add some selectors.
We can start by adding some base ones, to retrieve each property:
@Injectable()
export abstract class PaginatedItemsComponentStore
extends ComponentStore<PaginatedItemsState>
{
readonly selectPaginatedItemsState = this.select((state) => state);
readonly selectPaginationDetails = this.select(
this.selectPaginatedItemsState,
({ paginationDetails }) => paginationDetails
);
readonly selectOffset = this.select(
this.selectPaginationDetails,
({ offset }) => offset
);
readonly selectPageSize = this.select(
this.selectPaginationDetails,
({ pageSize }) => pageSize
);
readonly selectPageContent = this.select(
this.selectPaginatedItemsState,
({ pageContent }) => pageContent
);
readonly selectTodoItems = this.select(
this.selectPageContent,
({ todoItems }) => todoItems
);
}
That's a lot of selectors but keep in mind that the purpose of this store is to be flexible enough so that any store that inherits from it can use those internals instead of rewriting them somewhere else
Handling pagination
From our store, let's add some updaters to update our state:
@Injectable()
export abstract class PaginatedItemsComponentStore
extends ComponentStore<PaginatedItemsState>
{
/* Selectors omitted here */
private readonly updatePagination = this.updater(
(state, paginationDetails: PaginationDetails) => ({ ...state, paginationDetails })
);
private readonly updatePaginatedItems = this.updater(
(state, pageContent: PageContent) => ({ ...state, pageContent })
);
}
I will be using one for the pagination and one for the paginated items but feel free to be more or less granular if you want to!
Finally, we can copy, paste and adapt a bit our two previous loadPage
and loadNextPage
effects from our AppComponentStore
:
@Injectable()
export abstract class PaginatedItemsComponentStore
extends ComponentStore<PaginatedItemsState>
{
/* Selectors omitted here */
// π Don't forget to also inject our service
private readonly _todoItemService = inject(TodoItemService);
readonly loadPage = this.effect((trigger$: Observable<void>) => {
return trigger$.pipe(
// π We can directly access our pagination details from our selector
withLatestFrom(this.selectPaginationDetails),
switchMap(([, { offset, pageSize }]) =>
this._todoItemService.getTodoItems(offset, pageSize).pipe(
tapResponse(
(todoItems: TodoItem[]) => this.updatePaginatedItems({ todoItems }),
() => console.error("Something went wrong")
)
)
)
);
});
readonly loadNextPage = this.effect((trigger$: Observable<void>) => {
return trigger$.pipe(
// π Same here
withLatestFrom(this.selectPaginationDetails),
tap(([, { offset, pageSize }]) => this.updatePagination({
offset: offset + pageSize,
pageSize
})),
tap(() => this.loadPage())
);
});
/* Updaters omitted here */
}
Using NgRx lifecycle hooks
As we did in our first version, we can also take advantage of the OnStoreInit
lifecycle hook here to load the page on startup:
@Injectable()
export abstract class PaginatedItemsComponentStore
extends ComponentStore<PaginatedItemsState>
implements OnStoreInit
{
ngrxOnStoreInit() {
this.loadPage();
}
}
By doing so, we can ensure that each store paginating items, and thus inheriting this one, will load the first page upon creation
Inheriting from our store
Finally, all that is left to do is for our AppComponentStore
to inherit from our PaginatedItemsComponentStore
instead of ComponentStore<AppState>
For that, we need to change our initial state and get rid of our own AppState
to use the provided PaginatedItemsState
:
// app.component-store.ts
const initialState: PaginatedItemsState = {
paginationDetails: {
offset: 0,
pageSize: 10,
},
pageContent: {
todoItems: [],
},
};
Once this is done, we can delete all updaters, effects and lifecycle hooks implementations from our component store and use instead the logic of the PaginatedComponentStore
:
@Injectable()
export class AppComponentStore
extends PaginatedItemsComponentStore
{
readonly vm$ = this.select(
this.selectTodoItems,
(todoItems) => ({ todoItems }));
constructor() {
super(initialState);
}
}
If you run your application after this change, everything should still be working as it was before, yay!
Introducing generics
So far so good but let's not rejoice so fast
If everything as been as easy as copying and pasting code from our former store to the new one, it is only because we are constraining it to TodoItem
s
In a regular application, you may (surely) have more than one type of entity to paginate
Fortunately, TypeScript handles generics pretty well and this is something we can leverage to increase the reusability of our PaginatedItemsComponentStore
Rework our state
The first place to look at is our PageContent
interface
In this one, we are defining the content as an array of TodoItem
but we want to allow for a broader set of types
We may be tempted to change it to any[]
but since we are using _Type_Script and not _Any_Script, we may find a better way
Using generics, we can specify to our interface that we would like to have an array of items whose type will be TItem
since we don't know it yet:
export interface PageContent<TItem> {
// π Since we are manipulating items now I renamed the property
items: TItem[];
}
By convention, I'm naming any generic type by starting with a
T
followed by its logical meaning
Updating this interface will need us to rewrite the PaginatedItemsState
as well since we need to propagate the generics:
export interface PaginatedItemsState<TItem> {
paginationDetails: PaginationDetails;
pageContent: PageContent<TItem>;
}
Updating our store
With the updates made to the state, our store is no longer valid and we also need to propagate the generic type here
However, we don't want to use a concrete type yet or all our modifications would have been done for nothing
To address the fist compilation error, we will first need to also indicate our store that we will be using TItem
:
@Injectable()
export abstract class PaginatedItemsComponentStore<TItem>
extends ComponentStore<PaginatedItemsState<TItem>>
implements OnStoreInit { /* ... */ }
After doing so, there is two small errors we need to address:
- In our
selectTodoItems
selector,todoItems
does no longer exists since we have rename it toitems
. We can fix it by changing the property name: ```ts
readonly selectItems = this.select(
this.selectPageContent,
({ items }) => items
);
- In our `updatePaginatedItems` updated, `PageContent` is not aware of the generic type and we need to specify it:
```ts
private readonly updatePaginatedItems = this.updater(
(state, pageContent: PageContent<TItem>) => ({ ...state, pageContent })
);
However, we now face a bigger issue: in the loadPage
effect, we are calling the todoItemService
and this service is very specific to our TodoItem
s
Delegate the fetching logic
From our PaginatedItemsComponentStore
, there is no way for us to know in advance how a specific kind of TItem
will be retrieved given an offset and the page size
However, a class that will know that is the implementing one
Fortunately, we are in an abstract class and we can let the child class define its own logic by adding an abstract method:
protected abstract getItems(paginationDetails: PaginationDetails): Observable<TItem[]>;
Using this method, we can now remove the instance of our service and replace its call by the abstract method:
- private readonly _todoItemService = inject(TodoItemService);
readonly loadPage = this.effect((trigger$: Observable<void>) => {
return trigger$.pipe(
withLatestFrom(this.selectPaginationDetails),
switchMap(([, { offset, pageSize }]) =>
- this._todoItemService.getTodoItems(offset, pageSize).pipe(
+ this.getItems({ offset, pageSize }).pipe(
tapResponse(
- (todoItems: TodoItem[]) => this.updatePaginatedItems({ todoItems }),
+ (items: TItem[]) => this.updatePaginatedItems({ items }),
() => console.error("Something went wrong")
)
)
)
);
});
Updating the AppComponentStore
We're almost done! Now that our base store is generic, we need to specify in our AppComponentStore
that TItem
will be TodoItem
for us
// π Notice that we are now talking about `TodoItem`
const initialState: PaginatedItemsState<TodoItem> = {
paginationDetails: {
offset: 0,
pageSize: 10,
},
pageContent: {
items: [],
},
};
@Injectable()
export class AppComponentStore
// π Same here
extends PaginatedItemsComponentStore<TodoItem>
{
readonly vm$ = this.select(
// π Don't forget that our selector has been renamed
this.selectItems,
(todoItems) => ({ todoItems }));
}
However, we now also need to implement that getItems
method so that our parent component knows how to retrieve those TodoItem
s
For that, we will need to reinject a TodoItemService
instance and call it from there:
private readonly _todoItemService = inject(TodoItemService);
protected getItems({ offset, pageSize }: PaginationDetails): Observable<TodoItem[]> {
return this._todoItemService.getTodoItems(offset, pageSize);
}
Building our app again, everything should still be working as before but, this time, paginating a new type of entity won't need you to rewrite the whole component store again!
In this article we saw how to take advantage of generics to lift the common pagination logic to an abstract component that we can later extend
If you would like to go a bit further you can try to:
- Handle the loading and error logic
- Add extra selectors (first item of the page, etc.)
- Create a new
PostService
that is almost the same as theTodoItemService
except that it is retrieving posts by callinghttps://jsonplaceholder.typicode.com/posts
. You can then use this service to paginatePost
s instead ofTodoItem
s by defining a new component store inheriting fromPaginatedItemsComponentStore<Post>
If you would like to check the resulting code, you can head on to the associated GitHub repository
I hope that you learnt something useful there and, as always, happy coding!
Photo by Roman Trifonov on Unsplash
Top comments (2)
Very interesting post
Thanks a lot, glad you enjoyed it!