Earlier I explained how NgRx Component Stores can be used to paginate data.
Handling pagination with NgRx component stores
Pierre Bouillon for Playful Programming Angular ・ Feb 28 '23
In this write-up, we’ll approach the identical challenge using StateAdapt, a compact yet expanding state management solution that emphasizes composability, adaptability, and a declarative style.
📑 Like before, this piece is structured as a practical lab. If you’d like to follow along, keep an eye out for the '🧪' markers.
Initial State
Our project is a straightforward todo list that presents a collection of tasks:
At this stage, the pagination and data handling still live inside the component, but we're about to move them into a designated store.
Configuring StateAdapt
🧪 If you want to follow along, check out the
initial-setuptag to begin from that point.
Before we can manage state, the state management library needs to be set up.
To do this, we need to add the necessary dependencies:
npm i -D @state-adapt/core @state-adapt/rxjs @state-adapt/angular
After the installation completes, we’re ready to leverage the default store provider:
// 📁 src/main.ts
bootstrapApplication(AppComponent, {
providers: [defaultStoreProvider], // 👈 Provided here
}).catch((err) => console.error(err));
Everything is in place—now we're ready to build our adapters!
Creating the adapters
🧪 Check out the
with-state-adapttag to begin from this point
What sets StateAdapt apart is that it lets us specify how each slice of data is handled using dedicated adapters.
For this example, our focus is on managing todo items according to the active pagination.
Rather than tackling the whole state at once, we'll split it up and handle each component individually.
Adapting the Pagination
First, we'll set up an adapter to handle the pagination-related state.
Open a new file and pull out the interface there:
// 📁 src/app/pagination.ts
export interface Pagination {
offset: number;
pageSize: number;
}
At this point, we can go ahead and define the two actions that exist right now: moving to the following page and returning to the previous one:
// 📁 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,
},
});
📑 In a production setting, you might want to adjust page size as well, but I’ll keep it simple here!
Adapting the Todo Item
Following the same pattern we used for pagination, we're going to set up an adapter for the TodoItem now.
Since the interface is already defined, we only need to add the adapter on top of it.
To keep this example straightforward, our state will only have a selector for the item, with no extra logic:
// 📁 src/app/todo-item.ts
// ...
export const todoItemAdapter = createAdapter<TodoItem>()({
selectors: {
todoItem: (state) => state,
},
});
Now that we've prepared our TodoItem for adaptation, the reality is we won't be working with a single item—multiple items are in play.
While it's possible to build an adapter for TodoItem[], StateAdapt provides a more streamlined alternative for managing collections of entities: createEntityAdapter lets us repurpose existing logic without extra boilerplate.
By applying the adapter we've already set up, adapting a list of todo items becomes straightforward:
// 📁 src/app/todo-item.ts
// ...
export const todoItemsAdapter = createEntityAdapter<TodoItem>()(todoItemAdapter);
Because of this, our TodoItem adapters are complete in only a handful of lines. Next, let's turn our attention to the paginated items!
Adapting the Paginated Items
Within our TodoItemService, the state can be shaped as pagination metadata alongside a set of todo items.
Yet, because the TodoItem[] in the list function as entities, we ought to mark them accordingly. StateAdapt offers an EntityState type that accepts two generic parameters—one for the entity and one for the key designating its identity. Handling multiple TodoItems here equates to handling 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 {
// ...
}
At first glance, this may seem unusual, but it lets us leverage our existing adapters to shape the TodoItemsState via joinAdapters, requiring almost no additional code:
// 📁 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-adapterstag to get started from here
With every managed entity and piece of state now open to adaptation, the adapters still need to be applied in order to assemble the store.
To kick things off, we require a baseline, 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 {
// ...
}
📑 The
todoItemsslice is typed asEntityState<TodoItem, 'id'>, which is why thecreateEntityStatehelper is used to set up the starting value rather than passing in an empty array.
Given that starting point, setting up the store calls for only a minimal amount of additional code:
// 📁 src/app/todo-item.service.ts
// ...
@Injectable({ providedIn: 'root' })
export class TodoItemService {
readonly #store = adapt(initialState, {
adapter: todoItemsStateAdapter,
});
// ...
}
Defining the Actions
Now that the state is initialized and the store is in place, we can connect the actions that let us move between pages.
Within StateAdapt, you trigger an action by pushing a value into a Source, which serves as the channel for dispatching actions.
For this scenario, two sources are necessary: one for advancing to the following page, and another for going back to the preceding 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$,
},
});
// ...
}
💡 It's worth noting that StateAdapt generated these sources automatically, thanks to the
paginationAdapterbeing joined!
This approach updates the pagination state, but the TodoItems remain unchanged—a side effect is required to handle them.
Fundamentally, our goal is to fetch and assign the TodoItems whenever the pagination state shifts.
To achieve this, we begin by defining a method to set all TodoItems, mirroring our earlier pagination setup:
// 📁 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
},
});
// ...
}
Triggering a side effect works exactly like any other side effect defined with RxJs: simply subscribe to the relevant 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're nearly finished! The state we've built so far already works as a solid management solution, but there's no way to read it yet — so let's add selectors.
Reading Our State
A common approach to reading state is through view models, often abbreviated as "vm".
For our store, the view model can be a signal that combines the two pieces we care about: the pagination details and the todo list.
// 📁 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
combineLatestto create your view model:readonly vm = toSignal( combineLatest({ pagination: this.#store.pagination$, todoItems: this.#store.todoItemsAll$, }), { requireSync: true } );
Our state is now set up and accessible, so it's time to drop the component-level logic and depend on it instead!
Consuming the State
🧪 Check out the
with-storetag to begin from this point
Returning to our AppComponent, we can now strip out the manual logic from the code-behind and lean 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();
}
}
And in the same way, the template is now able to read the vm value directly:
// 📁 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 {
// ...
}
🧪 There's room to take this even further—why not shift the logic behind the
[disabled]attributes into a selector?
That wraps it up! Now, StateAdapt is in charge of pagination, which opens the door to extra capabilities such as built-in Redux DevTools support:
Takeaways
Throughout this piece, we explored pagination handling with StateAdapt serving as our state management tool, leveraging its composition-oriented API.
Our entities were gradually adapted to build the component state, and the store was then seeded from that foundation.
In the end, we retrieved it within our component to strip away the logic it housed.
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
You’ll likely pick up some valuable insights from that resource!
Photo by Sincerely Media on Unsplash


