Using IAsyncEnumerable in a Razor component

  • Gérald Barré

IAsyncEnumerable<T> is a new interface that is used to fetch asynchronous data. For instance, you can use it to fetch data from a paginated REST API. Indeed, you will need multiple async http requests to get all data, so it matches this interface. Let's see how you can use a method that returns an IAsyncEnumerable<T> instance in a Razor component.

Razor
@* ❌ This is not valid in a Blazor component as the rendering must be synchronous *@
<ul>
    @await foreach (var item in GetDataAsync())
    {
        <li>@item</div>
    }
</ul>

#Naive implementation

A solution is to use a List<T> to store the items from the enumerator and then re-render the page when a new item is available by calling StateHasChanged(). You can start the enumeration in any method of the Razor component lifecycle, such as the OnInitialized or OnParametersSet method.

Razor
<ul>
    @foreach (var item in items)
    {
        <li>@item</li>
    }
</ul>

@code {
    List<string> items = new();

    protected override async Task OnInitializedAsync()
    {
        await foreach (var text in GetStringsAsync())
        {
            items.Add(text);
            StateHasChanged();
        }
    }

    private async IAsyncEnumerable<string> GetStringsAsync()
    {
        for (int i = 0; i < 10; i++)
        {
            yield return $"item {i}";
            await Task.Delay(100);
        }
    }
}

This implementation works, but it is not very efficient. You may call StateHasChanged() a lot of times in a very short interval. A better solution would be to wait a few hundred milliseconds before calling StateHasChanged() again. So, if the enumerator is fast, you can call StateHasChanged() only once instead of hundreds of times.

#Throttling calls to StateHasChanged

I've already written about throttling events in a previous post. The idea is to call StateHasChanged() only once every few hundred milliseconds.

Razor
@page "/throttle"

<PageTitle>Index</PageTitle>

<ul>
    @foreach (var item in items)
    {
        <li>@item</li>
    }
</ul>

@code {
    List<string> items = new();

    protected override async Task OnInitializedAsync()
    {
        var throttledStateHasChanged = Throttle(
            () => InvokeAsync(StateHasChanged),
            TimeSpan.FromMilliseconds(500));

        await foreach (var text in GetStringsAsync())
        {
            items.Add(text);
            throttledStateHasChanged();
        }
    }

    private async IAsyncEnumerable<string> GetStringsAsync()
    {
        for (int i = 0; i < 10; i++)
        {
            yield return $"item {i}";
            await Task.Delay(100);
        }
    }

    // https://www.meziantou.net/debouncing-throttling-javascript-events-in-a-blazor-application.htm#throttle-debounce-on
    private static Action Throttle(Action action, TimeSpan interval)
    {
        Task task = null;
        var l = new object();
        return () =>
        {
            if (task != null)
                return;

            lock (l)
            {
                if (task != null)
                    return;

                task = Task.Delay(interval).ContinueWith(t =>
                {
                    action();
                    task = null;
                });
            }
        };
    }
}

#Cancelling the enumeration with the component is removed from the page

Another point to consider is to stop the enumeration when the component is removed from the page. Indeed, there is no need to use CPU/IO for useless work. The await foreach operator provides a way to specify a CancellationToken using the WithCancellationToken method. Then, you can use a CancellationTokenSource instance to create a token and pass it to the enumerator. Last, you can call the Cancel method in the Dispose method. Indeed, Blazor calls the Dispose method when the component is removed from the page.

Razor
@implements IDisposable

<ul>
    @foreach (var item in items)
    {
        <li>@item</li>
    }
</ul>

@code {
    private CancellationTokenSource cts = new();
    List<string> items = new();

    public void Dispose()
    {
        cts.Cancel();
        cts.Dispose();
    }

    protected override async Task OnInitializedAsync()
    {
        var stateHasChangedThrottled = Throttle(() => InvokeAsync(StateHasChanged), TimeSpan.FromMilliseconds(500));
        await foreach (var text in GetStringsAsync())
        await foreach (var text in GetStringsAsync().WithCancellation(cts.Token))
        {
            items.Add(text);
            stateHasChangedThrottled();
        }
    }

    private async IAsyncEnumerable<string> GetStringsAsync(CancellationToken cancellationToken = default)
    private async IAsyncEnumerable<string> GetStringsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        for (int i = 0; i < 100; i++)
        {
            yield return $"item {i}";
            await Task.Delay(100, cancellationToken);
        }
    }
}

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?Buy Me A Coffee💖 Sponsor on GitHub