Debounce search calls in Angular
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:
However, with debouncing implemented, no search requests are made until the user stops typing for a while:
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:
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
:
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.