In a previous article, I covered how to paginate data using NgRx Component Stores
Handling pagination with NgRx component stores
Pierre Bouillon for This is Angular ใป Feb 28 '23
In today's article, we are going to tackle the same problem but with StateAdapt, a small but growing state management library, focused on composability, evolutivity and declarative programming.
๐ As usual, this article is build as a hands on lab. You can code alongside me by following the notes marked by a '๐งช'.
Initial State
The application we will be working on will be a simple todo list, with a bunch of items displayed:
For now the pagination is handled by the component itself, along with the data, but we will be shifting this from the component into a dedicated store.
Configuring StateAdapt
๐งช Checkout the
initial-setup
tag to get started from here
Before managing our state, we will first need to configure the state management library.
For that, we will install the required packages:
npm i -D @state-adapt/core @state-adapt/rxjs @state-adapt/angular
Once installed, we can use the default store provider:
// ๐ src/main.ts
bootstrapApplication(AppComponent, {
providers: [defaultStoreProvider], // ๐ Provided here
}).catch((err) => console.error(err));
We're all set and ready for to create our adapters!
Creating the adapters
๐งช Checkout the
with-state-adapt
tag to get started from here
StateAdapt has the unique particularity of allowing us to define how to manage a specific piece of data through dedicated adapters.
In our case, we will be managing todo items based on the current pagination.
Instead of managing everything at once, we will break it down by managing each piece of part of our state.
Adapting the Pagination
To get started, we will start by creating the adapter of the pagination details.
In a new file, we can extract the interface:
// ๐ src/app/pagination.ts
export interface Pagination {
offset: number;
pageSize: number;
}
From there, we will be able to define the two actions we currently have: going to the next and the previous page:
// ๐ src/app/pagination.ts
// ...
export const paginationAdapter = createAdapter<Pagination>()({
nextPage: ({ offset, pageSize }) => ({ pageSize, offset: offset + 1 }),
previousPage: ({ offset, pageSize }) => ({ pageSize, offset: offset - 1 }),
selectors: {
pagination: (state) => state,
},
});
๐ You might also want to change the page size and more in a real application, I'm just sticking to the example here!
Adapting the Todo Item
As we just did for the pagination, we will now be creating an adapter for our TodoItem
.
This time the interface already exists, and we will just append the adapter.
For the example simplicity's sake, our state won't contain any logic, just a selector for the item itself:
// ๐ src/app/todo-item.ts
// ...
export const todoItemAdapter = createAdapter<TodoItem>()({
selectors: {
todoItem: (state) => state,
},
});
We're all set for adapting our TodoItem
but unfortunately we won't be manipulating a single todo item but several onces.
We could create an adapter for TodoItem[]
but instead StateAdapt offers an efficient and concise way of reusing existing logic for multiple entities with createEntityAdapter
.
With our previously defined adapter, we can now adapt a list of todo items fairly simply:
// ๐ src/app/todo-item.ts
// ...
export const todoItemsAdapter = createEntityAdapter<TodoItem>()(todoItemAdapter);
Thanks to that, our adapters for the TodoItem
are done, in just a few lines of code. It's time to work on the paginated items now!
Adapting the Paginated Items
Back in our TodoItemService
, we can define our state as a pagination details and a collection of todo items.
However, since the listed TodoItem[]
are considered as entities, we will tag them as such. StateAdapt provides a type named EntityState
that has two generic parameters: the entity itself and the name of the key used for its identity. In our case, managing several TodoItem
s drills down to managing an EntityState<TodoItem, 'id'>
:
// ๐ src/app/todo-item.service.ts
// ...
export interface TodoItemsState {
pagination: Pagination;
todoItems: EntityState<TodoItem, 'id'>;
}
@Injectable({ providedIn: 'root' })
export class TodoItemService {
// ...
}
While this might look strange at first, it allows us to reuse or previous adapters to adapt the TodoItemsState
using joinAdapters
, with little to no code to write:
// ๐ src/app/todo-item.service.ts
// ...
export const todoItemsStateAdapter = joinAdapters<TodoItemsState>()({
pagination: paginationAdapter,
todoItems: todoItemsAdapter,
})();
@Injectable({ providedIn: 'root' })
export class TodoItemService {
// ...
}
Creating Our Store
๐งช Checkout the
with-adapters
tag to get started from here
Now that all managed entities and state can be adapted, we still have to use the adapters to create the store.
First, we need a starting point, an initial state:
// ๐ src/app/todo-item.service.ts
// ...
const initialState: Readonly<TodoItemsState> = {
pagination: { offset: 0, pageSize: 5 },
todoItems: createEntityState<TodoItem, 'id'>(),
};
@Injectable({ providedIn: 'root' })
export class TodoItemService {
// ...
}
๐ Since
todoItems
is anEntityState<TodoItem, 'id'>
, we use the providedcreateEntityState
method for the initial value instead of an empty array.
With this initial state, creating our store doesn't require much more code:
// ๐ src/app/todo-item.service.ts
// ...
@Injectable({ providedIn: 'root' })
export class TodoItemService {
readonly #store = adapt(initialState, {
adapter: todoItemsStateAdapter,
});
// ...
}
Defining the Actions
With our state initialized and our store set up, we can now wire the actions allowing us to navigate between pages.
In StateAdapt, an action is triggered by nexting a Source
that acts as a medium of action emission.
In our case, we will need two source: one to navigate to the next page, and one to navigate to the previous one:
// ๐ src/app/todo-item.service.ts
// ...
@Injectable({ providedIn: 'root' })
export class TodoItemService {
readonly nextPage$ = new Source('[Todo Items State] Next Page');
readonly previousPage$ = new Source('[Todo Items State] Previous Page');
readonly #store = adapt(initialState, {
adapter: todoItemsStateAdapter,
sources: {
previousPaginationPage: this.previousPage$,
nextPaginationPage: this.nextPage$,
},
});
// ...
}
๐ก Notice that StateAdapt auto-generated the sources based on the joined
paginationAdapter
!
While this will update the pagination, the TodoItem
s won't be updated, and we will need to perform a side effect for that.
In essence, we would like to retrieve and set the TodoItem
s every time the pagination changes.
To do so, we will first define a way to set all TodoItem
s, the same way we did for the pagination:
// ๐ src/app/todo-item.service.ts
// ...
@Injectable({ providedIn: 'root' })
export class TodoItemService {
readonly nextPage$ = new Source('[Todo Items State] Next Page');
readonly previousPage$ = new Source('[Todo Items State] Previous Page');
readonly #setTodoItems$ = new Source<TodoItem[]>(
'[Todo Items State] Set Todo Items'
);
readonly #store = adapt(initialState, {
adapter: todoItemsStateAdapter,
sources: {
previousPaginationPage: this.previousPage$,
nextPaginationPage: this.nextPage$,
setTodoItemsAll: this.#setTodoItems$, ๐ New source
},
});
// ...
}
Performing a side effect is just as much as any side effect defined using RxJs: by subscribing to the appropriate observable:
// ๐ src/app/todo-item.service.ts
// ...
@Injectable({ providedIn: 'root' })
export class TodoItemService {
readonly nextPage$ = new Source<void>('[Todo Items State] Next Page');
readonly previousPage$ = new Source<void>('[Todo Items State] Previous Page');
// ๐ Since this side effect is internal, the visibility is private
readonly #setTodoItems$ = new Source<TodoItem[]>('[Todo Items State] Set Todo Items');
readonly #store = adapt(initialState, {
// ...
});
constructor() {
this.#store.pagination$
.pipe(
takeUntilDestroyed(),
switchMap((pagination) => this.getTodoItems(pagination))
)
.subscribe((todoItems) => this.#setTodoItems$.next(todoItems));
}
// ...
}
We are almost done! Our state is now a functional management solution, but we can't read it for now, let's add the selectors.
Reading Our State
A popular way of reading state is by defining view models, or "vm".
For our store, we can define the view model as a signal of the two things we are interested in: the pagination and the todo items:
// ๐ src/app/todo-item.service.ts
// ...
@Injectable({ providedIn: 'root' })
export class TodoItemService {
// ...
readonly vm = toSignal(
this.#store.state$.pipe(
map((state) => ({
pagination: state.pagination,
todoItems: Object.values(state.todoItems.entities),
}))
),
{ requireSync: true }
);
constructor() {
// ...
}
// ...
}
๐ You can also use
combineLatest
to create your view model:readonly vm = toSignal( combineLatest({ pagination: this.#store.pagination$, todoItems: this.#store.todoItemsAll$, }), { requireSync: true } );
Our state is now initialized and readable, it's time to ditch the logic in the component and rely on it instead!
Consuming the State
๐งช Checkout the
with-store
tag to get started from here
Back in our AppComponent
, we can now remove the custom logic from the code behind and rely on the service instead:
// ๐ src/app/app.component.ts
@Component({
// ...
})
export class AppComponent {
readonly #todoItemService = inject(TodoItemService);
readonly vm = this.#todoItemService.vm;
onPreviousPage(): void {
this.#todoItemService.previousPage$.next();
}
onNextPage(): void {
this.#todoItemService.nextPage$.next();
}
}
Similarly, we can now consume the vm
from the template:
// ๐ src/app/app.component.ts
@Component({
selector: 'app-root',
standalone: true,
imports: [TodoItemComponent],
template: `
@for (todoItem of vm().todoItems; track todoItem.id) {
<app-todo-item [todoItem]="todoItem" />
}
<div class="grid">
<button
type="button"
(click)="onPreviousPage()"
[disabled]="vm().pagination.offset === 0"
>
โ
</button>
<button
type="button"
(click)="onNextPage()"
[disabled]="vm().pagination.offset === 2"
>
โ
</button>
</div>
`,
})
export class AppComponent {
// ...
}
๐งช You could still go further, what about moving the condition for the
[disabled]
attributes to a selector?
And we are done! However, our pagination is now handled by StateAdapt that will also give us access to additional features like Redux DevTools out of the box:
Takeaways
In this article, we saw how we could handle pagination using StateAdapt as our state management solution, by taking advantage of its API focused on composition.
We incrementally adapted our entities to create the component state, and initialized our store from that point.
Finally, we consumed it from our component in order to remove the logic that it defined.
If you would like to see the resulting code, you can browse the article's repository:
pBouillon / DEV.HandlingPaginationWithStateAdapt
Demo code for the "Handling pagination with StateAdapt" article on DEV
Handling pagination with StateAdapt
Demo code for the "Handling pagination with StateAdapt" article on DEV
I hope that you learn something useful there!
Photo by Sincerely Media on Unsplash
Top comments (0)