This answer is the middle ground between the previous answers, i.e. between DIY and using a full-blown reactive UI framework.
It utilizes the powerful Reactive.Extensions library (a.k.a. Rx), which in my opinion is the only reasonable way to solve such problems in normal scenarios.
The solution
After installing the NuGet package System.Reactive
you can import the needed namespaces in your component:
@using System.Reactive.Subjects
@using System.Reactive.Linq
Create a Subject
field on your component that will act as the glue between the input event and your Observable
pipeline:
@code {
private Subject<ChangeEventArgs> searchTerm = new();
// ...
}
Connect the Subject
with your input
:
<input type="text" class="form-control" @oninput=@searchTerm.OnNext>
Finally, define the Observable
pipeline:
@code {
// ...
private Thing[]? things;
protected override async Task OnInitializedAsync() {
searchTerm
.Throttle(TimeSpan.FromMilliseconds(200))
.Select(e => (string?)e.Value)
.Select(v => v?.Trim())
.DistinctUntilChanged()
.SelectMany(SearchThings)
.Subscribe(ts => {
things = ts;
StateHasChanged();
});
}
private Task<Thing[]> SearchThings(string? searchTerm = null)
=> HttpClient.GetFromJsonAsync<Thing[]>($"api/things?search={searchTerm}")
}
The example pipeline above will...
- give the user 200 milliseconds to finish typing (a.k.a. debouncing or throttling the input),
- select the typed value from the
ChangeEventArgs
,
- trim it,
- skip any value that is the same as the last one,
- use all values that got this far to issue an HTTP GET request,
- store the response data on the field
things
,
- and finally tell the component that it needs to be re-rendered.
If you have something like the below in your markup, you will see it being updated when you type:
@foreach (var thing in things) {
<ThingDisplay Item=@thing @key=@thing.Id />
}
Additional notes
Don't forget to clean up
You should properly dispose the event subscription like so:
@implements IDisposable // top of your component
// markup
@code {
// ...
private IDisposable? subscription;
public void Dispose() => subscription?.Dispose();
protected override async Task OnInitializedAsync() {
subscription = searchTerm
.Throttle(TimeSpan.FromMilliseconds(200))
// ...
.Subscribe(/* ... */);
}
}
Subscribe()
actually returns an IDisposable
that you should store and dispose along with your component. But do not use using
on it, because this would destroy the subscription prematurely.
Open questions
There are some things I haven't figured out yet:
- Is it possible to avoid calling
StateHasChanged()
?
- Is it possible to avoid calling
Subscribe()
and bind directly to the Observable
inside the markup like you would do in Angular using the async
pipe?
- Is it possible to avoid creating a
Subject
? Rx supports creating Observable
s from C# Event
s, but how do I get the C# object for the oninput
event?