Tech Incent
Angular

Angular Search and pagination with example

angular search and pagination

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>

Related posts

Angular reactive form complete example: step-by-step

Sajal Mia

Angular JWT token authentication (login, auth and error interceptors, guard, protected route) example: Step by step

Sajal Mia

Angular lifecycle hooks explanation

Sajal Mia

Create forms using angular FormBulder

Sajal Mia

Angular Bootstrap 5 Popup Registration Form

Sajal Mia

Angular Production-Ready Project Setup

Sajal Mia