Blazor - Display wait or spinner on API call

In addition to @dani's answer here I'd like to point out that there are two separate problems here, and it pays to separate them.

  1. When to call StateHasChanged()

Blazor will (conceptually) call StateHasChanged() after initialization and before and after events. That means you usually don't need to call it, only when your method has several distinct steps and you want to update the UI in the middle do you need to call it. And that is the case with a spinner.

You do need to call it when you use fire-and-forget (async void) or when changes come from a different source, like a Timer or events from another layer in your program.

  1. How to make sure the UI is updated after calling StateHasChanged()

StateHasChanged() by itself does not update the UI. It merely queus a render operation. You can think of it as setting a 'dirty flag'.

Blazor will update the UI as soon as the render engine gets to run on its Thread again. Much like any other UI framework all UI operations have to be done on the main thread. But your events are also running (initially) on that same thread, blocking the renderer.

To resolve that, make sure your events are async by returning async Task. Blazor fullly supports that. Do not use async void. Only use void when you do not need async behaviour.

2.1 Use an async operation

When your method awaits an async I/O operation quickly after StateHasChanged() then you are done. Control will return to the Render engine and your UI will update.

 statusMessage = "Busy...";
 StateHasChanged();
 response = await SomeLongCodeAsync();  // show Busy
 statusMessage = "Done.";

2.2 Insert a small async action

When your code is CPU intensive it will not quickly release the main thread. When you call some external code you don't always know 'how async' it really is. So we have a popular trick:

 statusMessage = "Busy...";
 StateHasChanged();
 await Task.Delay(1);      // flush changes - show Busy
 SomeLongSynchronousCode();
 statusMessage = "Done.";

the more logical version of this would be to use Task.Yield() but that tends to fail on WebAssembly.

2.3 Use an extra Thread with Task.Run()

When your eventhandler needs to call some code that is non-async, like CPU-bound work, and you are on Blazor-Server you can enlist an extra pool Thread with Task.Run() :

 statusMessage = "Busy...";
 StateHasChanged();
 await Task.Run( _ => SomeLongSynchronousCode());  // run on other thread
 statusMessage = "Done.";

When you run this on Blazor-WebAssembly it has no effect. There are no 'extra threads' available in the Browser environment.

When you run this on Blazor-Server you should be aware that using more Threads may harm your scalability. If you plan to run as many concurrent clients as possible on a server then this is a de-optimization.

When you want to experiment:

void SomeLongSynchronousCode()
{ 
   Thread.Sleep(3000);
}

Task SomeLongCodeAsync()
{ 
   return Task.Delay(3000);
}

Option 1: Using Task.Delay(1)

  • Use an async method.
  • Use await Task.Delay(1) or await Task.Yield(); to flush changes
private async Task AsyncLongFunc()    // this is an async task
{
    spinning=true;
    await Task.Delay(1);      // flushing changes. The trick!!
    LongFunc();               // non-async code
    currentCount++;
    spinning=false;
    await Task.Delay(1);      // changes are flushed again    
}

Option 1 is a simple solution that runs ok but looks like a trick.

Option 2: Using Task.Run() (not for WebAssembly)

On January'2020. @Ed Charbeneau published BlazorPro.Spinkit project enclosing long processes into task to don't block the thread:

Ensure your LongOperation() is a Task, if it is not, enclose it into a Task and await for it:

async Task AsyncLongOperation()    // this is an async task
{
    spinning=true;
    await Task.Run(()=> LongOperation());  //<--here!
    currentCount++;
    spinning=false;
}

Effect

a spinner loading data

Spinner and server side prerendering

Because Blazor Server apps use pre-rendering the spinner will not appear, to show the spinner the long operation must be done in OnAfterRender.

Use OnAfterRenderAsync over OnInitializeAsync to avoid a delayed server-side rendering

    // Don't do this
    //protected override async Task OnInitializedAsync()
    //{
    //    await LongOperation();
    //}

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {            
            await Task.Run(()=> LongOperation());//<--or Task.Delay(0) without Task.Run
            StateHasChanged();
        }
    }

More samples

Learn more about how to write nice spinner you can learn from open source project BlazorPro.Spinkit, it contains clever samples.

More Info

See Henk Holterman's answer with blazor internals explanation.


Lot's of great discussion surrounding StateHasChanged(), but to answer OP's question, here's another approach for implementing a spinner, universally, for HttpClient calls to a backend API.

This code is from a Blazor Webassembly app...

Program.cs

public static async Task Main(string[] args)
{
    var builder = WebAssemblyHostBuilder.CreateDefault(args);
    builder.RootComponents.Add<App>("#app");
    
    builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
    builder.Services.AddScoped<SpinnerService>();
    builder.Services.AddScoped<SpinnerHandler>();
    builder.Services.AddScoped(s =>
    {
        SpinnerHandler spinHandler = s.GetRequiredService<SpinnerHandler>();
        spinHandler.InnerHandler = new HttpClientHandler();
        NavigationManager navManager = s.GetRequiredService<NavigationManager>();
        return new HttpClient(spinHandler)
        {
            BaseAddress = new Uri(navManager.BaseUri)
        };
    });

    await builder.Build().RunAsync();
}

SpinnerHandler.cs
Note: Remember to uncomment the artificial delay. If you use the out-of-the-box Webassembly template in Visual Studio, click the Weather Forecast to see a demo of the spinner in action.

public class SpinnerHandler : DelegatingHandler
{
    private readonly SpinnerService _spinnerService;

    public SpinnerHandler(SpinnerService spinnerService)
    {
        _spinnerService = spinnerService;
    }

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        _spinnerService.Show();
        //await Task.Delay(3000); // artificial delay for testing
        var response = await base.SendAsync(request, cancellationToken);
        _spinnerService.Hide();
        return response;
    }
}

SpinnerService.cs

public class SpinnerService
{
    public event Action OnShow;
    public event Action OnHide;

    public void Show()
    {
        OnShow?.Invoke();
    }

    public void Hide()
    {
        OnHide?.Invoke();
    }
}

MainLayout.razor

@inherits LayoutComponentBase

<div class="page">
    <div class="sidebar">
        <NavMenu />
    </div>

    <div class="main">
        <div class="top-row px-4">
            <a href="http://blazor.net" target="_blank" class="ml-md-auto">About</a>
        </div>

        <div class="content px-4">
            @Body
            <Spinner />
        </div>
    </div>
</div>

Spinner.razor
Note: To add some variety, you could generate a random number in the OnIntialized() method, and use a switch statement inside the div to pick a random spinner type. In this method, with each HttpClient request, the end user would observe a random spinner type. This example has been trimmed to just one type of spinner, in the interest of brevity.

@inject SpinnerService SpinnerService

@if (isVisible)
{
    <div class="spinner-container">
        <Spinner_Wave />
    </div>
}

@code
{
    protected bool isVisible { get; set; }

    protected override void OnInitialized()
    {
        SpinnerService.OnShow += ShowSpinner;
        SpinnerService.OnHide += HideSpinner;
    }

    public void ShowSpinner()
    {
        isVisible = true;
        StateHasChanged();
    }

    public void HideSpinner()
    {
        isVisible = false;
        StateHasChanged();
    }
}

Spinner-Wave.razor
Credit to: https://tobiasahlin.com/spinkit/
Note: There is a Nuget package for this spin kit. The drawback to the Nuget package is that you don't have direct access to the CSS to make tweaks. Here I've tweaked thee size of the spinner, and set the background color to match the site's primary color, which is helpful if you are using a CSS theme throughout your site (or perhaps multiple CSS themes)

@* Credit: https://tobiasahlin.com/spinkit/ *@

<div class="spin-wave">
    <div class="spin-rect spin-rect1"></div>
    <div class="spin-rect spin-rect2"></div>
    <div class="spin-rect spin-rect3"></div>
    <div class="spin-rect spin-rect4"></div>
    <div class="spin-rect spin-rect5"></div>
</div>
<div class="h3 text-center">
    <strong>Loading...</strong>
</div>

<style>
    .spin-wave {
        margin: 10px auto;
        width: 200px;
        height: 160px;
        text-align: center;
        font-size: 10px;
    }

        .spin-wave .spin-rect {
            background-color: var(--primary);
            height: 100%;
            width: 20px;
            display: inline-block;
            -webkit-animation: spin-waveStretchDelay 1.2s infinite ease-in-out;
            animation: spin-waveStretchDelay 1.2s infinite ease-in-out;
        }

        .spin-wave .spin-rect1 {
            -webkit-animation-delay: -1.2s;
            animation-delay: -1.2s;
        }

        .spin-wave .spin-rect2 {
            -webkit-animation-delay: -1.1s;
            animation-delay: -1.1s;
        }

        .spin-wave .spin-rect3 {
            -webkit-animation-delay: -1s;
            animation-delay: -1s;
        }

        .spin-wave .spin-rect4 {
            -webkit-animation-delay: -0.9s;
            animation-delay: -0.9s;
        }

        .spin-wave .spin-rect5 {
            -webkit-animation-delay: -0.8s;
            animation-delay: -0.8s;
        }

    @@-webkit-keyframes spin-waveStretchDelay {
        0%, 40%, 100% {
            -webkit-transform: scaleY(0.4);
            transform: scaleY(0.4);
        }

        20% {
            -webkit-transform: scaleY(1);
            transform: scaleY(1);
        }
    }

    @@keyframes spin-waveStretchDelay {
        0%, 40%, 100% {
            -webkit-transform: scaleY(0.4);
            transform: scaleY(0.4);
        }

        20% {
            -webkit-transform: scaleY(1);
            transform: scaleY(1);
        }
    }
</style>

It's beautiful

Animation of result

Tags:

C#

Blazor