In this article, I will implement pagination and search for large amounts of data. This pagination and search will be optimized with the debounce technique. I also use some of RxJs operators like debounceTime, distinctUntilChanged, switchMap, exhaustMapcand more. And I will use fake product pagination data API from StoreRestApi.
Explanation search feature
Our goal for the search is, there will be a search field that takes input value. When the user inputs some value after .5s from stop input values that will call API.
We must be aware of every input change API calling, For example, if the search term is “shirt”, we don’t want to call for every letter (s,h,i,r,t). Search should be called when the shirt word inputs complete. For such this problem we will use debounceTime operator. We will not call API if input value is not changed using distinctUntilChanged operator
Let’s declare searchControl as Angular reactive form FormControl for html input binding. We are going to use FormControl for input binding because it gives a wide range of input handling methods.
Declare searchTerm$ property as searchControl valueChanges that will return Observable of search value. It takes 4 RxJs operators.
- The first takeUntil will handle the subscription until the component is destroyed. If you use angular 16+ version os you can use takeUntilDestroyed operator from @angular/core/rxjs-interop.
- debounceTime will prevent every input change API, But it will call after 0.5 seconds from inputting the stop
- distinctUntilChanged handles save value API call
- startWith(“”) will do emit a first empty string to this Observable
searchControl = new FormControl<string>(''); private searchTerm$ = this.searchControl.valueChanges.pipe( takeUntil(this._destroy$), debounceTime(500), distinctUntilChanged(), startWith('') )
Here are the HTML search elements. The input field takes searchControl as formControl
<div style="max-width: 300px;margin: 50px auto;"> <label for="id_search">Search: </label> <input [formControl]="searchControl" type="search" id="id_search" placeholder="Search title, description..."> </div>
Explanation Pagination
Approach: Our pagination has the next and preview page route. and pagination page item limit can be updated.
Declare paginationEvent$ as Subject. paginationEvent$ is an emitter to APi calling. when the pagination page or limit change paginationEvent$ will next which is emitter. The setPage method updates the page number and emits a signal when a user requests a new page. The setItemLen method update the page limit
// Pagination Emitter to apu private paginationEvent$ = new Subject<void>(); // page event handler setPage(pageNumber: number): void { this.queryParams.page = pageNumber; this.paginationEvent$.next(); } // Paination page item length handler setItemLen(e: any): void { this.queryParams.page = 1; this.queryParams.limit = e.target?.value; this.paginationEvent$.next(); }
Html pagination element
<section style="display: flex;align-items: center;justify-content: center;margin-top: 1rem;gap: 16px;"> <div> <label for="id_item_end"> Item per page: <select id="id_item_end" (change)="setItemLen($event)" [value]="queryParams.limit"> <option [value]="6">6</option> <option [value]="9">9</option> <option [value]="12">12</option> <option [value]="21">21</option> <option [value]="42">42</option> </select> </label> </div> <div> <p>Current Page: {{queryParams.page}}</p> </div> <div style="display: flex;gap: 5px;"> <button style="cursor: pointer;" [disabled]="queryParams.page === 1" (click)="setPage((queryParams.page || 1) - 1)" type="button">Prev</button> <button style="cursor: pointer;" [disabled]="isLastPage" (click)="setPage((queryParams.page || 1) + 1)" type="button">Next</button> </div> </section>
Let’s go to proper example scenarios
Creating new project
new new angular-search-and-pagination
Generate product module and product service and productList component
ng g m product --routing ng g c product/productList ng g s product/product
In the app-routing.module.ts file
... const routes: Routes = [ { path: '', pathMatch: 'full', loadChildren: () => import('./product/product.module').then(m => m.ProductModule) } ]; ...
Routing product list component, In the product-routing.module.ts file
... const routes: Routes = [ { path: '', component: ProductListComponent } ]; ...
In the app.component.html file
<router-outlet></router-outlet>
Add HttpClientModule and ReactiveFormModule in the product.module.ts file
import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ProductRoutingModule } from './product-routing.module'; import { ProductListComponent } from './product-list/product-list.component'; import { HttpClientModule } from '@angular/common/http'; import { ReactiveFormsModule } from '@angular/forms'; @NgModule({ declarations: [ ProductListComponent ], imports: [ CommonModule, ProductRoutingModule, HttpClientModule, ReactiveFormsModule ] }) export class ProductModule { }
Implement product.service.ts
We are going to use StoreRestApi products API. That API provides search and pagination features also. Let’s define QueryParam type for safety. Then define the getProducts method which returns httpClient observable of products response. getProducts method takes Partial queryParam.
import { Injectable } from '@angular/core'; import { HttpClient} from '@angular/common/http'; import { Observable } from 'rxjs'; export type QueryParam = { q: string, page: number, limit: number } @Injectable({ providedIn: 'root' }) export class ProductService { readonly baseUrl = 'https://api.storerestapi.com'; constructor(private httpClient: HttpClient) {} getProducts(queryParams?: Partial<QueryParam>): Observable<any> { const httoOptions = { params: queryParams } return this.httpClient.get(this.baseUrl + '/products', httoOptions) } }
Final product.component.ts file
Now we have queryParams, queryParams store page number, item per page as item, search term as q. We also have searchTerm$ Observable<string> of search, paginationEvent$ subject which is pagination emitter. Now it can merge into a single subscription and call productApi.
keep SearchTerm$ upper level, So if any search input changes, it’s triggered from beginning. Then switchMap paginationEvent$, this will trigger pagination event, You notice, I am using switchMap here because when multiple search events trigger, this switchMap will cancel the preview one. Then inside the paginationEvent$ exhaustMap productList Api, Here is used exhaustMap because it will complete one request than go next request
import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; // import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ProductService, QueryParam } from '../product.service'; import { Subject, debounceTime, distinctUntilChanged, exhaustMap, startWith, switchMap, takeUntil } from 'rxjs'; import { FormControl } from '@angular/forms'; @Component({ selector: 'app-product-list', templateUrl: './product-list.component.html', styleUrls: ['./product-list.component.scss'] }) export class ProductListComponent implements OnInit, OnDestroy { private _destroy$ = new Subject<void>() products: any[] = []; isLoading: boolean = true; isLoaded: boolean = false; isLastPage: boolean = false; queryParams: Partial<QueryParam> = { page: 1, limit: 9 } searchControl = new FormControl<string>(''); private searchTerm$ = this.searchControl.valueChanges.pipe( takeUntil(this._destroy$), debounceTime(500), distinctUntilChanged(), startWith('') ) private paginationEvent$ = new Subject<void>(); constructor(private productService: ProductService) {} ngOnInit(): void { // Subscription to products this.searchTerm$ .pipe( switchMap((searchTerm: string | null) => { console.log('term', searchTerm) this.queryParams.page = 1; if (searchTerm !== null && searchTerm !== '') { this.queryParams['q'] = searchTerm } return this.paginationEvent$.pipe( startWith(undefined), exhaustMap(() => { this.isLoading = true; return this.productService.getProducts(this.queryParams) }) ) }) ) .subscribe({ next: (res: Record<string, any>) => { this.products = res['data']; this.queryParams['page'] = res['metadata']?.currentPage; this.isLoading = false if (!this.isLoaded) { this.isLoaded = true; } this.isLastPage = res['metadata']?.currentPage === res['metadata']?.totalPages }, error: (err: any) => { console.log(err) this.isLoading = false } }) } ngOnDestroy(): void { this._destroy$.next(); this._destroy$.subscribe(); } setPage(pageNumber: number): void { this.queryParams.page = pageNumber; this.paginationEvent$.next(); } setItemLen(e: any): void { this.queryParams.page = 1; this.queryParams.limit = e.target?.value; this.paginationEvent$.next(); } }
Final product.component.html file
<section class="product_area"> <div style="max-width: 1200px;margin: 50px auto;"> <div style="max-width: 300px;margin: 50px auto;"> <label for="id_search">Search: </label> <input [formControl]="searchControl" type="search" id="id_search" placeholder="Search title, description..."> </div> <!-- Products --> <div *ngIf="!isLoaded && isLoading;else productContainerTemplate"> Loading ... </div> </div> <ng-template #productContainerTemplate> <div style="display: grid;grid-template-columns: repeat(3, minmax(0, 1fr));gap: 16px;"> <div *ngFor="let product of products" style="border: 1px solid gray; border-radius: 4px; padding: 1rem;"> <div class="p-5"> <a [routerLink]="['/products', product.slug]"> <h3 style="margin-top: 0;">{{product.title}}</h3> </a> <p class="mb-3 font-normal text-gray-700 dark:text-gray-400">Here are the biggest enterprise technology acquisitions of 2021 so far, in reverse chronological order.</p> <a [routerLink]="['/products', product.slug]" style="display: inline-flex;align-items: center; gap: 12px;"> Read more <svg style="width: 15px;height: 15px;" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 10"> <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 5h12m0 0L9 1m4 4L9 9"/> </svg> </a> </div> </div> </div> <div *ngIf="isLoading" style="display: flex;justify-content: center;margin-top: 20px;"> Loading more... </div> <section style="display: flex;align-items: center;justify-content: center;margin-top: 1rem;gap: 16px;"> <div> <label for="id_item_end"> Item per page: <select id="id_item_end" (change)="setItemLen($event)" [value]="queryParams.limit"> <option [value]="6">6</option> <option [value]="9">9</option> <option [value]="12">12</option> <option [value]="21">21</option> <option [value]="42">42</option> </select> </label> </div> <div> <p>Current Page: {{queryParams.page}}</p> </div> <div style="display: flex;gap: 5px;"> <button style="cursor: pointer;" [disabled]="queryParams.page === 1" (click)="setPage((queryParams.page || 1) - 1)" type="button">Prev</button> <button style="cursor: pointer;" [disabled]="isLastPage" (click)="setPage((queryParams.page || 1) + 1)" type="button">Next</button> </div> </section> </ng-template> </section>