Debounce search calls in Blazor WebAsm

March 25th 2022 Blazor ReactiveX

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):

Timeline of search queries submitted and 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:

Timeline of search queries and results when using 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:

The new search query is made before the previous result is received

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:

The last search result is received before the previous one

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 with Select, 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 by Select. However, unlike SelectMany, 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 by Select 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:

Ignore results of old search queries if they were received after a new query was submitted

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.

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

If you're looking for online one-on-one mentorship on a related topic, you can find me on Codementor.
If you need a team of experienced software engineers to help you with a project, contact us at Razum.
Copyright
Creative Commons License