Creating custom pagination component with Angular

Creating custom pagination component with Angular

Pagination is undoubtedly one of the most popular approaches to dozed content representation. And it’s absolutely fine to use some out-of-the-box solutions for it if your application uses some library like NgBootstrap or Material UI all round and the designs and functionality they provide is satisfactory to you.
But once you bump into the need of re-styling or customisation of it you usually may choose the way of creating a wrapping component which uses this out-of-the-box component inside and re-define its behaviour or styles by breaking original component encapsulation.
This is a legit approach of course if there are few changes to make. But when you start modifying it over and over trying to satisfy some special customer needs there may be a point when you say: “Come on! It would be easier and faster to create it from scratch than to do all these customisations!”
And you will be absolutely right here! Let’s explore together the way how to implement it in your Angular app.

Impatient readers can find the whole project here. The rest are welcome to follow with me step by step.


I like to think about pagination as about select control: we have the list of options (pages) and select one of them. And this is the reason to implement it as a form control. And speaking in terms of Angular it means that we want to implement ControlValueAccessor interface. There is an official documentation and a lot of other instructions and descriptions how to implement it. So, we’re not going to dwell on it and only will deal with pagination specific of it.

Of course you may say that we could make this component without implementing ControlValueAccessor interface just using @Output property for handling page change events. This point of view is absolutely reasonable and furthermore we’ll have this version of our component here at first as well. But the benefit of implementing ControlValueAccessor is that it gives us more flexibility: we can use or pagination component either as a template form with one- or two-way data binding ([(ngModel)] or [ngModel]+ (ngModelChange)) or as a reactive form control ([formControl]).

The reactive approach can be very handy when our pagination is supposed to be used in combination with other form controls. For example, we may have a table component which offers user a possibility to sort and filter data by columns, use pagination and table lookup. In this case we can use RxJS combineLatest method to subscribe and listen to the changes of all these controls only once:

combineLates([ this.searchControl.pipe( startWith(this.searchControl.value), debounceTime(500) ), this.filterControl.pipe(startWith(this.filterControl.value)), this.sortControl.pipe(startWith(this.sortControl.value)), this.pageControl.pipe( startWith(this.pageControl.value), debounceTime(500) )
]).subscribe((search, filter, sort, pagination) => { this.sendRequest({ search, filter, sortBy: sort.sortBy, sortDirection: sort.sortDirection, page: pagination.page, pageSize: pagination.pageSize };
})
Enter fullscreen mode Exit fullscreen mode

As we you can see above with this approach we have to pipe startWith to each control, otherwise our subscription won’t start until each control emits at leas one value. In this example we use the initial form values to start with.

Another advantage of reactive approach is the possibility to use debounceTime to particular controls which allows us to avoid triggering requests on each key stroke in search input or on each click on pagination Previous / Next button.


So, let’s finally start with pagination itself!

At the first step for the visualising purpose we create a simple array of 100 elements which we’ll paginate later:

// app.component.html <div class="items-wrapper"> <my-card *ngFor="let item of visibleItems.items" [item]="item"> </my-card>
</div> // app.component.ts export interface PaginatedResponse<T> { items: T[]; total: number;
} ... public pageSize = 10; private readonly items = Array.from( Array(100).keys(), (item) => item + 1
); public visibleItems: PaginatedResponse<number> = { items: this.items.slice(0, this.pageSize), total: this.items.length,
};
Enter fullscreen mode Exit fullscreen mode

As a rule the backend sends us a paginated response, which have at least 2 properties:

  • items — array of items for the particular (requested) page
  • total — how many matching items they have in the whole.

We’re going to follow this structure in our example even though we don’t have any backend in this example. We’ll just be slicing needed range of the initial array imitating backend responses.


On the second step we create a simple pagination component which would allow us to navigate to the first, previous, next, last and particular page from the visible range with no interactions with parent component.

I’m saying visible range because we may have our content split into dozens or hundreds of pages, and we definitely don’t want to show all of them to the user. So we provide visibleRangeLength property to limit the visible pages count.

// pagination.component.html <div class="pagination"> <button [disabled]="value === 1" (click)="selectPage(1)"> << </button> <button [disabled]="value === 1" (click)="selectPage(value - 1)"> < </button> <button *ngFor="let page of visiblePages" [ngClass]="value === page && 'selected'" (click)="selectPage(page)"> {{ page }} </button> <button [disabled]="value === totalPages (click)="selectPage(value + 1)"> > </button> <button [disabled]="value === totalPages" (click)="selectPage(totalPages)> >> </button>
</div> // pagination.component.ts public value = 1;
public totalPages = 10;
public visibleRangeLength = 5;
public visiblePages: number[]; ngOnInit(): void { this.updateVisiblePages();
} public selectPage(page: number): void { this.value = page; this.updateVisiblePages();
} private updateVisiblePages(): void { const length = Math.min(this.totalPages, this.visibleRangeLength); const startIndex = Math.max( Math.min( this.value - Math.ceil(length / 2), this.totalPages - length ), 0 ); this.visiblePages = Array.from( new Array(length).keys(), (item) => item + startIndex + 1 );
}
Enter fullscreen mode Exit fullscreen mode

The only tricky part here I guess is updateVisiblePages method. The main points of it are the following:

  • we always want to have visible as many pages as we set in visibleRangeLength property
  • if visibleRangeLength turns out to be bigger than totalPages count (and this is absolutely possible as we never know how many pages the backend has for us, especially if we request filtered results) we want to display totalPages count
  • the selected page should be in the middle of the visible range if possible

Now this component is already functional but it can’t interact with external environment. To turn our component into useable configurable solution we need to:

  • convert value, totalPages, visibleRangeLength into component inputs
  • to add component output valueChange and extend selectPage method with the line this.valueChange.emit(this.value)

Though we can refactor it a little bit. As we have said previously we usually don’t get totalPages value from the backend, but we do get total items quantity and then we calculate the totalPages on our side depending on the page size. So, instead of having this logic in the AppComponent let’s put it in the PaginationComponent:

  • convert totalPages back into the regular public property
  • provide input properties total (for the total items count) and pageSize
  • add re-calculation of the totalPages value each time our total or pageSize change

After finishing it our component looks like this:

// pagination.component.ts @Input() value = 1;
@Input() total = 10;
@Input() pageSize = 10;
@Input() visibleRangeLength = 5;
@Output() valueChange = new EventEmitter<number>(); public totalPages: number;
public visiblePages: number[]; ngOnInit(): void { this.updateVisiblePages();
} ngOnChanges(changes: SimpleChanges): void { if (changes.total || changes.pageSize) this.updateTotalPages();
} public selectPage(page: number): void { this.value = page; this.updateVisiblePages(); this.valueChange.emit(this.value);
} private updateVisiblePages(): void { const length = Math.min(this.totalPages, this.visibleRangeLength); const startIndex = Math.max( Math.min( this.value - Math.ceil(length / 2), this.totalPages - length ), 0 ); this.visiblePages = Array.from( new Array(length).keys(), (item) => item + startIndex + 1 );
} private updateTotalPages(): void { this.totalPages = Math.ceil(this.total / this.pageSize);
}
Enter fullscreen mode Exit fullscreen mode

Now we can apply the pagination to our list of items:

// app.component.html ... <div class="pagination-wrapper"> <my-pagination [total]="visibleItems.total" [pageSize]="pageSize" (valueChange)="onPageChange($event)"> </my-pagination>
</div> // app.component.ts ... public onPageChange(page: number): void { const startIndex = (page - 1) * this.pageSize; const items = this.items.slice( startIndex, startIndex + this.pageSize ); this.visibleItems = { items, total: this.items.length };
}
Enter fullscreen mode Exit fullscreen mode

On the third step I want to make our component responsible not only for the page but also for the page size selection. So we’ll add the select element to our pagination template and extend and refactor the component logic a bit more.

Obviously, our component should emit page size change event outside. But the business logic may be different here depending on the customer preferences. One may want the page content not to be updated on page size change and only request new data set on page number change. Another may have different thoughts about it.

I personally think that the most transparent way to handle it would be to request the new range of data on the page size change reseting the selected page to 1 in the same time. And this is the approach we’re going to implement further.

At first we’ll extend value interface so that it includes two properties page and pageSize. This will also need to do some refactoring in the existing PaginationComponent code (like replacing value with value.page etc.).

Then we’ll add the following changes:

// pagination.component.html ... <select [ngModel]="value.pageSize" (ngModelChange)="selectPageSize($event)"> <option *ngFor="let size of pageSizes" [value]="size"> {{ size }} </option>
</select> // pagination.component.ts ... export interface PaginationValue { page: number; pageSize: number;
}
...
@Input() value: PaginationValue = { page: 1, pageSize: 5 };
...
@Input() pageSizes: number[] = [5, 10, 25, 50];
@Output() valueChange = new EventEmitter<PaginationValue>();
...
ngOnChanges(changes: SimpleChanges): void { if (changes.total || changes.value) this.updateTotalPages();
} public selectPage(page: number): void { this.value = { ...this.value, page }; ...
} public selectPageSize(pageSize: string): void { this.value = { page: 1, pageSize: +pageSize }; this.updateTotalPages(); this.updateVisiblePages(); this.valueChange.emit(this.value);
}
Enter fullscreen mode Exit fullscreen mode

And finally let’s turn our component into the ControlValueAccessor. For this we only need to remove everything related to the output property valueChange and add the following:

@Component({ ... providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => PaginationComponent), multi: true, }]
}) export class PaginationComponent implements OnInit, OnChanges, ControlValueAccessor { ... onChange(value: any) {}; onTouched() {}; registerOnChange(fn: any): void { this.onChange = fn; } registerOnTouched(fn: any): void { this.onTouched = fn; } writeValue(value: PaginationValue): void { if (!value) return; this.value = value; this.updateTotalPages(); this.updateVisiblePages(); } public selectPage(page: number): void { ... this.onChange(this.value); } public selectPageSize(pageSize: number): void { ... this.onChange(this.value); }
}
Enter fullscreen mode Exit fullscreen mode

And now we can use the pagination as a form control:

// app.component.html ... <my-pagination [total]="visibleItems.total" [formControl]="paginationControl">
</my-pagination> <!-- OR --> <my-pagination [total]="visibleItems.total" [ngModel]="pagination" (ngModelChange)="onPageChange($event)">
</my-pagination> // app.component.ts ... public pagination = { page: 1, pageSize: 10 }; // OR public readonly paginationControl = new FormControl(this.pagination); ngOnInit(): void { this.paginationControl.valueChanges .subscribe(this.onPageChange.bind(this));
} public onPageChange(pagination: PaginationValue): void { const startIndex = (pagination.page - 1) * pagination.pageSize; const items = this.items.slice( startIndex, startIndex + pagination.pageSize ); this.visibleItems = { items, total: this.items.length };
}
Enter fullscreen mode Exit fullscreen mode

Thank you for reading it and for your feedbacks!


Discover more from Coursity

Subscribe to get the latest posts to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *