Customizing Angular mat-autocomplete to handle a million records

Anil Reddy A
4 min readJul 9, 2020

--

Angular material provides a wide range of features when working with mat-autocomplete combined with filtering, but where it fails mostly is handling large set of data. Today, we are going to discuss on how we can customize the Angular mat-autocomplete to hold over a million records, having performance as our first priority.

Now, I’m going to build a generic component, where we need to pass the set of records that the mat-autocomplete will show.Here, we are building this reusable component using Angular, Angular Material, Rxjs. Considering, you all have good experience Angular, I’m moving forward.

Since, we are creating a reusable component, we should let most of the parameters to be passed from parent component, so it would be more of reusable without major changes.

Now, we are creating mat-autocomplete.ts where we will let users input for formControl, smartList(Array of objects to be shown inside dropdown) etc… There’s an Event emitter which will emit the selected option to parent component.

mat-autocomplete.ts

import {Component,OnInit,Input,Output,EventEmitter,OnDestroy} from "@angular/core";
import { FormControl } from "@angular/forms";
import { Observable, of, Subject } from "rxjs";
import {startWith,map,debounceTime,filter,switchMap,exhaustMap,tap,scan,
takeWhile } from "rxjs/operators";
export interface MatInput {
id: number ;
name: string;
sequence?: number;
}
@Component({
selector: "app-auto-complete",
templateUrl: "./auto-complete.component.html",
styleUrls: ["./auto-complete.component.css"]
})
export class AutoCompleteComponent implements OnInit, OnDestroy {@Input() fieldCtrl: FormControl;
smartList = [];
@Input("smartList") set updateSmartList(data: any) {
if (data && data.length) {
this.smartList = data;
this.getFilteredList();
} else {
this.smartList = [];
this.getFilteredList();
}
}
@Input() placeHolder: string; // placeholder for autocomplete@Input() appearance?: string; // appearance for autocomplete(eg: 'outline', 'legacy' etc...)@Output() optionSelected = new EventEmitter();// emits the option selected
filteredList: Observable<MatInput[]>;
private nextPage$ = new Subject();
constructor() {}ngOnInit(): void {
this.getFilteredList();
}
getChangedValOfInput() {
const filter$ = this.fieldCtrl.valueChanges.pipe(
startWith(""),
debounceTime(400)
// Note: If the option value is bound to object, after selecting the option
// Note: the value will change from string to {}. We want to perform search
// Note: only when the type is string (no match)
// filter(q => typeof q === 'string')
);
return filter$;
}
getFilteredList() {
const filter$ = this.getChangedValOfInput();
this.filteredList = filter$.pipe(
switchMap(currInputVal => {
// Note: Reset the page with every new seach text
let currentPage = 1;
return this.nextPage$.pipe(
startWith(currentPage),
// Note: Until the backend responds, ignore NextPage requests.
exhaustMap(_ => this.getItems(currInputVal, currentPage)),
tap(() => currentPage++),
// Note: This is a custom operator because we also need the last emitted value.
// Note: Stop if there are no more pages, or no results at all for the current search text.
takeWhile(p => p.length > 0, true),
scan(
(allProducts, newProducts) => allProducts.concat(newProducts)
)
);
})
);
}
private getItems(startsWith: any, page: number): Observable<MatInput[]> {
const take = 10;
const skip = page > 0 ? (page - 1) * take : 0;
let filterValue = "";
if ((startsWith || {}).name) {
filterValue = (startsWith.name || "").toLowerCase();
} else {
filterValue = (startsWith || "").toString().toLowerCase();
}
const filtered = this.smartList.filter(
(option: any) => option.name.toLowerCase().indexOf(filterValue) >= 0
);
return of(filtered.slice(skip, skip + take));
}
onSelect(event: any) {
this.optionSelected.emit(event);
}
onScroll() {
this.nextPage$.next();
}
displayFn(data: any): string {
return data && data.name ? data.name : '';
}
ngOnDestroy() {
this.nextPage$.unsubscribe();
if (this.optionSelected) {
this.optionSelected.unsubscribe();
}
}
}

We can use the properties present in .ts file in HTML like below

mat-autocomplete.html

<mat-form-field class="example-full-width" appearance="{{ appearance }}">  <input matInput placeholder="{{placeHolder}}"[matAutocomplete]="inputAuto" [formControl]="fieldCtrl" (keyup)="onSelect($event)" /><mat-autocomplete #inputAuto="matAutocomplete" showPanel="true"(optionsScroll)="onScroll()" [displayWith]="displayFn">      <mat-option *ngFor="let element of filteredList | async" [value]="element" (click)="onSelect(element)" [title]="(element || {}).name">{{element.name}}
</mat-option>
</mat-autocomplete>
</mat-form-field>

Now lets edit the options-scroll.directive.ts file where autocomplete panel scrolling logic will go.

options-scroll.directive.ts

import { Directive, EventEmitter, Input, Output,OnDestroy } from "@angular/core";
import { MatAutocomplete } from "@angular/material/autocomplete";
import { Subject } from "rxjs";
import { tap, takeUntil } from "rxjs/operators";
export interface IAutoCompleteScrollEvent {
autoComplete: MatAutocomplete;
scrollEvent: Event;
}
@Directive({
selector: "mat-autocomplete[optionsScroll]"
})
export class OptionsScrollDirective implements OnDestroy {
timeoutRef: any;
@Input() thresholdPercent = 0.8;
@Output("optionsScroll") scroll = new EventEmitter<IAutoCompleteScrollEvent>();
_onDestroy = new Subject();
constructor(public autoComplete: MatAutocomplete) { this.autoComplete.opened.pipe(tap(() => {
// Note: When autocomplete raises opened, panel is not yet created (by Overlay)
// Note: The panel will be available on next tick
// Note: The panel wil NOT open if there are no options to display
this.timeoutRef = setTimeout(() => {
// Note: remove listner just for safety, in case the close event is skipped.
this.removeScrollEventListener();
this.autoComplete.panel.nativeElement.addEventListener(
"scroll",
this.onScroll.bind(this)
);
});
}),
takeUntil(this._onDestroy)
)
.subscribe();
this.autoComplete.closed
.pipe(
tap(() => this.removeScrollEventListener()),
takeUntil(this._onDestroy)
)
.subscribe();
}
private removeScrollEventListener() {
if (this.autoComplete.panel) {
this.autoComplete.panel.nativeElement.removeEventListener(
"scroll",
this.onScroll
);
}
}
ngOnDestroy() {
clearTimeout(this.timeoutRef);
this._onDestroy.next();
this._onDestroy.complete();
this.removeScrollEventListener();
}
onScroll(event: any) {
if (this.thresholdPercent === undefined) {
this.scroll.next({ autoComplete: this.autoComplete, scrollEvent: event });
} else {
const threshold = (this.thresholdPercent * 100 * event.target.scrollHeight) / 100;
const current = event.target.scrollTop + event.target.clientHeight;
if (current > threshold) {
this.scroll.next({
autoComplete: this.autoComplete,
scrollEvent: event
});
}
}
}
}

Now, the reusable component is ready for use. Here’s an example of using it from parent component.

generalForm: FormGroup;languages = [
{
id: 1,
element: "english",
value: "eng",
name: "English"
},
{
id: 2,
element: "telugu",
value: "tel",
name: "Telugu"
}
];
constructor() {
for (let i = 0; i < 99999; i++) {
this.languages.push({
id: i + 3,
element: "English" + i,
value: "eng" + i,
name: "English" + i
});
}
this.generalForm = new FormGroup({
language: new FormControl()
});
selectedOption($event){
console.log(event) // here we can get the selected option
}
}

Using the reusable component in HTML file

<form [formGroup]="generalForm"><app-auto-complete [fieldCtrl]="generalForm.controls.language" [smartList]="languages" [placeHolder]="'Select Language'"[appearance]="'outline'" (optionSelected)="selectedOption($event)" ></app-auto-complete></form>

Conclusion

Similarly, we can use the above logic while working with mat-chip autocomplete, which I’ll be covering in my next article.

Now we have done with code. Here is the link to the working example of Stackblitz. Its my first ever article online. I would appreciate any feedback to improve further.

--

--