This article explains how to build a reactive and reusable Angular component in a declarative way, using a “template-first” approach.
So, we need a component!
We need to display a table of items, with pagination, each column should be sortable in ascending or descending order.
Let’s use ng g c items-table
to generate a component, and then add file items-table.store.ts to the folder, generated by Angular CLI. If you don’t know why I call it “store”, this article is for you.
I recommend using stores for non-trivial components and moving most of the logic there. This way it will be much easier to reuse code (not bound to the template anymore) and to communicate from child components.
It is not so significant detail for the approach I’m describing in this article: if you prefer to put it all into the component itself — I don’t mind at all.
An important part of this approach is to start creating the template first, not anything else.
We have an empty template, generated by Angular CLI:
<p>items-table works!</p>
We need a table, so let’s put a table here:
<table class="table table-sm table-striped">
<thead>
<tr>
@for (header of store.$headers(); track header.path) {
}
</tr>
</thead>
</table>
Your IDE will start complaining that you don’t have store.$headers()
yet. But let’s ignore it for now and just keep creating our template — it is better to don’t interrupt this process and express what we need in the template first, without thinking about the implementation.
<table class="table table-sm table-striped">
<thead>
<tr>
@for (header of store.$headers(); track header.path) {
<th (click)="store.changeSorting(header.path)">
<span class="d-flex flex-row align-items-center gap-2">
<span>{{ header.label }}</span>
@if (store.$sortByHeader() === header.path) {
@if (store.$sortingOrder() === 'asc') {
<ng-container *ngTemplateOutlet="iconSortDown"></ng-container>
} @else {
@if (store.$sortingOrder() === 'desc') {
<ng-container *ngTemplateOutlet="iconSortUp"></ng-container>
}
}
}
</span>
</th>
}
</tr>
</thead>
<tbody>
@for (item of store.$items(); track item) {
<tr>
@for (header of store.$headers(); track header.path) {
<td>{{ item[header.path] }}</td>
}
</tr>
}
</tbody>
</table>
<!-- Pagination -->
<div class="d-flex flex-row justify-content-center">
<div class="btn-group">
<button type="button" class="btn btn-outline-primary" (click)="store.prevPage()">⬅️</button>
<button type="button" disabled class="btn btn-primary">{{ store.$page() + 1 }}</button>
<button type="button" class="btn btn-outline-primary" (click)="store.nextPage()">➡️</button>
</div>
</div>
Now we’ve declared in our component what we need to render it.
You can find the code, described in this article, in this repository: 🔗 GitHub.
Now, we can start implementing all the data bindings and events handlers.
Reactive Data Bindings
I recommend using Signals in Angular templates as the reactive containers of data.
The first signal we need to create is store.$headers()
:
// items-table.store.ts
export type ItemsTableHeader = {
path: keyof Item;
label: string;
}
@Injectable()
export class ItemsTableStore {
readonly $headers = signal<ItemsTableHeader[]>([]);
}
The next highlighted thing is an event handler, but let’s postpone it for now, and take the next signals: $sortByHeader()
and $sortingOrder()
:
// items-table.store.ts
readonly $sortByHeader = signal<string | undefined>(undefined);
readonly $sortingOrder = signal<'asc' | 'desc'>('asc');
And now, the most interesting part, $items()
!
We could naively implement it like this:
readonly $items = signal<Item[]>([]);
But, then for pagination and sorting we would need to overwrite the value of that signal. It is an imperative approach, and it is not good enough for us: this way $items would have multiple sources of truth: component input, pagination side effects, and sorting. They all would write into our signal, and we would have to control in what order they should do this…
The imperative approach is only simpler when the first line is written, later imperative code evolves into a more and more complicated mess.
Let’s use the declarative approach instead.
Derived values
The initial source of items is the component’s inputs — and we should react to the changes in this input too:
@Component({
selector: 'items-table',
// ...
providers: [ItemsTableStore],
})
export class ItemsTableComponent {
protected readonly store = inject(ItemsTableStore);
@Input({ required: true }) set items(items: Item[]) {
if (items) {
this.store.setItems(items);
}
}
}
When we produce the list of items, the last thing that will decide what items we should render on this page is pagination.
// items-table.store.ts
readonly $itemsPerPage = signal<number>(5);
readonly $page = signal<number>(0);
private readonly $inputItems = signal<Item[]>([]);
readonly $items = computed(() => {
const items = this.$inputItems();
// pagination:
const page = this.$page();
const perPage = this.$itemsPerPage();
if (page === 0 && items.length <= perPage) {
return items;
}
return items.slice(page * perPage, page * perPage + perPage);
});
setItems(items: Item[]) {
this.$inputItems.set(items.slice());
}
Items should be sorted, in selected order, and sorting should happen before pagination, otherwise, we’ll only sort items on the selected page.
So let’s create a computed signal that will produce sorted items from $inputItems()
, and our paginated $items()
will use that produced result as input:
// items-table.store.ts
readonly $sortedItems = computed(() => {
const sortPath = this.$sortByHeader();
if (!sortPath) {
return this.$inputItems();
}
const items = this.$inputItems().slice();
items.sort((a, b) => {
const aVal = a[sortPath];
const bVal = b[sortPath];
if (typeof aVal === 'number' && typeof bVal === 'number') {
return aVal < bVal ? -1 : (aVal > bVal ? 1 : 0);
} else {
return a[sortPath].toString().localeCompare(b[sortPath].toString());
}
});
if (this.$sortingOrder() === 'asc') {
return items;
} else {
return items.reverse();
}
});
readonly $items = computed(() => {
const items = this.$sortedItems();
// pagination:
// ...
}
All the data bindings are implemented, let’s implement event handlers.
Side Effects
The first event handler, changeSorting()
, is a function that will return nothing but after its execution, the state of our store will be modified — it is a side effect of that function, and often such functions are called “side effects” (or “effects”) for brevity.
Side effects are the only place where our imperative code should be located. As you might noticed, all the computed()
signals just return the result, based on inputs, and don’t have side effects (they don’t write to signals). It makes them pure functions. Writing to signals is not allowed in computed()
exactly to make them pure functions. I recommend using computed()
because of this — your code will be declarative and clean.
// items-table.store.ts
changeSorting(path: keyof Item) {
if (path === this.$sortByHeader()) {
if (this.$sortingOrder() === 'asc') {
this.$sortingOrder.set('desc');
} else {
this.$sortByHeader.set(undefined);
}
} else {
this.$sortByHeader.set(path);
this.$sortingOrder.set('asc');
}
}
Handlers prevPage() and nextPage() are simple:
// items-table.store.ts
prevPage() {
const page = this.$page();
if (page > 0) {
this.$page.set(page - 1);
}
}
nextPage() {
const page = this.$page();
const items = this.$sortedItems();
if (items.length > ((page + 1) * this.$itemsPerPage())) {
this.$page.set(page + 1);
}
}
I omitted the implementation of headers input of our component in the article, but you can find it in the source code (it is too simple to be illustrative).
That’s it!
Now our component will render a table of items, and the content of this table is reactive — it will be modified in response to user actions.
In the source code, you can check out how the parent component (app.component.ts) uses our component. Also, if you open the resulting app in multiple tabs, and start switching “collections”, you’ll see they all synced across tabs, using just 1 line of code 😎
You can play with the deployed app using this link.
🪽 Do you like this article? Share it and let it fly! 🛸
💙 If you enjoy my articles, consider following me on Twitter, and/or subscribing to receive my new articles by email.
Top comments (0)