Debounce search calls in Blazor WebAsm
In modern user interfaces, it is common to respond to user input immediately, without waiting for the user to submit it by pressing Enter or clicking a button. For example, when the user enters the query in the input field, the search is already performed to provide the result as soon as possible. sible.
This can be inefficient because the user is usually not interested in the intermediate results for each letter, but only in the whole word they are typing. Since a search query usually takes longer than the user needs to type a single letter, the timeline will probably look like this (the first line represents queries submitted and the second represents results received):
Without compromising usability, we could wait until the user stops typing for a while, and only then submit the search query by skipping the intermediate queries while the user is still typing. A common term for this behavior is debouncing:
Of course, you can always implement such behavior yourself. But it's better to save yourself some time and use well-tested libraries that already do this. If you write your application in Blazor, you can use Reactive Extensions.
Learning the basics of this cross-platform library is far beyond the scope of this post. The official website is a good place to start. I'll just focus on how you use the library to implement debouncing behavior in Blazor.
The core of this approach is an IObservable<string>
implementation, a Subject<string>
to be exact:
private readonly Subject<string?> searchSubject = new();
When the user types something into the input field, we send the current query as values into this subject. To accomplish this, we handle the input
event of the input
element:
<input
type="text"
class="form-control"
id="searchQuery"
@oninput="OnSearchQueryInput"
/>
OnSearchQueryInput
performs some basic validation and cleanup on the input, and then sends it into the subject as the next value:
public void OnSearchQueryInput(ChangeEventArgs args)
{
var searchQuery = args.Value?.ToString();
searchSubject.OnNext(searchQuery?.Trim());
}
The rest of the processing is done by applying Reactive operators to the resulting sequence of values. These can be defined during the initialization of the component:
protected override void OnInitialized()
{
base.OnInitialized();
searchSubscription = searchSubject
.Throttle(TimeSpan.FromMilliseconds(300))
.DistinctUntilChanged()
.SelectMany(async searchQuery =>
await SearchService.SearchAsync(searchQuery))
.Subscribe(results =>
{
SearchResults = results;
StateHasChanged();
});
}
Let us explain each operator in turn:
Throttle
is the debouncing implementation in Reactive Extensions for .NET. The time span parameter specifies the time that must elapse since the last new value for the current value to be passed on.DistinctUntilChanged
skips a value if it is identical to the previous one, and passes it on only if it differs.SelectMany
executes an asynchronous method for each value and passes the result of each asynchronous method immediately as it becomes available.Subscribe
completes the chain by performing an action for each value received. It assigns the result to a local property on the page and reports a change to trigger a re-render.
The return value of the Subscribe
method is an IDisposable
that must be called to clean up resources when leaving the page. For this to work, you should implement IDisposable
:
@implements IDisposable
And call Dispose
on the subscription in its Dispose
method:
private IDisposable? searchSubscription;
public void Dispose()
{
searchSubscription?.Dispose();
}
The code above implements debouncing, but there are still some problems with it. If the user continues typing after a short pause before getting the result of the previous query, the results of both queries are rendered. Although a newer query has already been made, the result of the old query is displayed until the result of the newest query finally arrives:
While this is not necessarily a bug, the problem worsens if the result of the second search query is received before the result of the first search query. In this case, the results of the old search query are rendered when received and hide the correct result of the last search query:
This problem can be fixed by changing the operators applied to the original sequence of search queries:
searchSubscription = searchSubject
.Throttle(TimeSpan.FromMilliseconds(300))
.DistinctUntilChanged()
.Select(async searchQuery =>
await SearchService.SearchAsync(searchQuery))
.Switch()
.Subscribe(search =>
{
SearchResults = results;
StateHasChanged();
});
Two changes have been made:
SelectMany
has been replaced withSelect
, which does not wait for the result of the asynchronous method. Instead, the task of the asynchronous method is emitted as the next value in the sequence.Switch
then awaits the tasks issued bySelect
. However, unlikeSelectMany
, it discards the previous task when it receives the next one, and outputs the result of a task only if no new task has yet been output bySelect
when it has completed.
This ensures that the results of old queries are ignored if a new query has been issued before the result has been received:
You can find the full source code in my GitHub repository. The individual commits show the evolution of the code as described here. The code logs search requests in the browser console so that the behaviors described in this post can be observed.
Debouncing can dramatically reduce the number of backend calls from an application while improving the user experience by preventing problems that might occur when backend response times vary widely. You could implement such a feature yourself, but using a proven library instead will save you time and avoid errors.