Debounce search calls in Angular

April 8th 2022 Angular ReactiveX

In a recent blog post, I addressed the issue of debouncing user input in Blazor applications. I used the example of a search query input field. Without debouncing, a search request would be made for every letter the user types:

Timeline of search queries submitted and results received

However, with debouncing implemented, no search requests are made until the user stops typing for a while:

Timeline of search queries and results when debouncing is used

In Angular, we could use a debounce helper function like _.debounce from Underscore.js to achieve this. But to have more control over it, we can use RxJS just like in Blazor. This makes even more sense because Angular also uses RxJs a lot internally, for example in its HTTP client API.

For this to work, we first need to convert the user input into an observable that will output the value when it changes:

private readonly searchSubject = new Subject<string | undefined>();

We want to handle the input event of the input element to get the current query value as the user types:

<input
  type="text"
  class="form-control"
  id="searchQuery"
  (input)="onSearchQueryInput($event)"
/>

In onSearchQueryInput, we can then clean up the input value and send it to the subject:

public onSearchQueryInput(event: Event): void {
  const searchQuery = (event.target as HTMLInputElement).value;
  this.searchSubject.next(searchQuery?.trim());
}

We are now ready to process our observable with RxJS operators. We define the processing in ngOnInit:

public ngOnInit(): void {
  this.searchSubscription = this.searchSubject
    .pipe(
      debounceTime(300),
      distinctUntilChanged(),
      switchMap((searchQuery) => this.searchService.search(searchQuery))
    )
    .subscribe((results) => (this.searchResults = results));
}

The logic is implemented with the three operators passed to the pipe function:

  • debounceTime is the implementation of debouncing in RxJS. The parameter specifies the number of milliseconds that must elapse since the last new value for the current value to be passed.
  • distinctUntilChanged skips a value if it is identical to the previous one and passes it only if it is different.
  • switchMap outputs the value from the observable returned by the call to the search function in its parameter when it becomes available.

We should unsubscribe from the subscription returned by the subscribe function. For this purpose, we store it in a local field and call unsubscribe in ngOnDestroy:

private searchSubscription?: Subscription;

public ngOnDestroy(): void {
  this.searchSubscription?.unsubscribe();
}

There is one more important detail regarding the switchMap operator that is worth mentioning. As soon as the searchSubject outputs a new value that gets projected into an observable by the switchMap lambda parameter, the value output by the previous observable is ignored. This means that the results of the previous search query will not be displayed if they are received after submitting a new search query:

Ignore results of old search queries if received after submitting a new search query

In our case, this is the desired behavior. Otherwise, the result of a previous search query could even overwrite the result of the last search query if we received this response later than the one of the last query.

However, if we wanted to output results from all observables, not just the last one, we could use the mergeMap operator instead of switchMap:

Show results of old queries even if they were received after a new query was submitted

You can find the full source code in my GitHub repository. In the last commit, I even added logging to make it clear that in the current implementation, results of previous search requests are not displayed if they were received after a new request was submitted.

Debouncing can dramatically reduce the number of backend calls from an application. You can use one of the many out-of-the-box implementations to add it to your Angular project. If you want more control to avoid issues that can arise with wildly fluctuating backend response times, you can implement it yourself using RxJS. Angular also uses this library internally, which makes integration even easier.

Get notified when a new blog post is published (usually every Friday):

Copyright
Creative Commons License