David23g
David23g

Reputation: 463

Display wait or spinner on API call

In my Blazor app I am making an API call to a back end server that could take some time. I need to display feedback to the user, a wait cursor or a "spinner" image. How is this done in Blazor?

I have tried using CSS and turning the CSS on and off but the page is not refreshed until the call is completed. Any suggestions would be greatly appreciated.

@functions {
    UserModel userModel = new UserModel();
    Response response = new Response();
    string errorCss = "errorOff";
    string cursorCSS = "cursorSpinOff";

    protected void Submit()
    {
        //Show Sending...
        cursorCSS = "";
        this.StateHasChanged();
        response = Service.Post(userModel);
        if (response.Errors.Any())
        {
            errorCss = "errorOn";
        }
        //turn sending off
        cursorCSS = "cursorSpinOff";
        this.StateHasChanged();
    }
}

Upvotes: 46

Views: 58070

Answers (10)

Husam Ebish
Husam Ebish

Reputation: 6808

Not just for API call, but for every service call:

SpinnerService:

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

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

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

Spinner component:

Your spinner hier, in my case I have MudProgressCircular

@using SpinnerService Namespace....
@inject SpinnerService spinnerService;

@if (IsVisible)
{
    <MudProgressCircular Color="Color.Primary"
                         Style="position: absolute;top: 50%;left: 50%;"
                         Indeterminate="true" />
}

@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();
    }
}

ServiceCaller:

using Microsoft.Extensions.DependencyInjection;

public class ServiceCaller
{
    private readonly IServiceProvider services;
    private readonly SpinnerService spinnerService;

    public ServiceCaller(IServiceProvider services, SpinnerService spinnerService)
    {
        this.services = services;
        this.spinnerService = spinnerService;
    }

    public async Task<TResult> CallAsync<TService, TResult>(Func<TService, TResult> method)
        where TService : class
    {
        var service = this.services.GetRequiredService<TService>();

        try
        {
            spinnerService.Show();

            await Task.Delay(500); // ToDo: this line is not necessary

            TResult? serviceCallResult = await Task.Run(() => method(service));

            return serviceCallResult;
        }
        finally
        {
            spinnerService.Hide();
        }
    }

     public async Task CallAsync<TService, TAction>(Func<TService, Action> method)
        where TService : class
    {
        var service = this.services.GetRequiredService<TService>();

        try
        {
            spinnerService.Show();

            await Task.Delay(500); // ToDo: this line is not necessary

            await Task.Run(() => method(service).Invoke());
        }
        finally
        {
            spinnerService.Hide();
        }
    }
}

How to use it?

@page "/temp"

@inject ServiceCaller serviceCaller;

<h3>Temp Page</h3>

<MudButton OnClick="CallMethodReturnsString">CallMethodReturnsString</MudButton>

<MudButton OnClick="CallVoidMethodAsync">CallVoidMethodAsync</MudButton>

<MudButton OnClick="CallTaskMethodAsync">CallTaskMethodAsync</MudButton>

<MudButton OnClick="CallMany">CallMany</MudButton>


@if (!string.IsNullOrEmpty(tempMessage)){
    @tempMessage
}

@code{
    string tempMessage = string.Empty;

    // call method returns string
    private async Task CallMethodReturnsString()
    {
        await serviceCaller.CallAsync<ITempService, string>(async x => this.tempMessage = await x.RetrieveStringAsync());
    }

    // call void method
    private async Task CallVoidMethodAsync()
    {
        await serviceCaller.CallAsync<ITempService, Task>(x => () => x.MyVoidMethod());
    }

    // call task method
    private async Task CallTaskMethodAsync()
    {
        await serviceCaller.CallAsync<ITempService, Task>(x => () => x.TaskMethod());
    }

    // call many methods
    private async Task CallMany()
    {
        await serviceCaller.CallAsync<ITempService, Action>(x => async () =>
        {
            this.tempMessage = await x.RetrieveStringAsync();
            x.MyVoidMethod();
            x.TaskMethod();
        });
    }
}

Upvotes: 2

Brian Fontana
Brian Fontana

Reputation: 672

Lots 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();
    }

    public void Dispose()
    {
        SpinnerService.OnShow -= ShowSpinner;
        SpinnerService.OnHide -= HideSpinner;
    }
}

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

Upvotes: 13

willingdev
willingdev

Reputation: 9646

I call web apis and application automatically show and hide MudOverlay without write any code for every api calls, like this:

protected override async Task OnInitializedAsync()
{
    users = await Http.GetFromJsonAsync<List<GetAllUsersDto>>("User/GetAll");
}

@if (users is not null) { @* MudDataGrid or MudTable *@ }

  1. create ApiService that containt ApiCount for save count of apis are executing. I choise byte data type for it beacuase 0 to 255 is enough.

    public class ApiService { public byte ApiCount { get; set; } = 0;

     public event Action ApiCountChanged;
    
     public void IncreaseApiCount()
     {
         ApiCount++;
         ApiCountChanged?.Invoke();
     }
    
     public void DecreaseApiCount()
     {
         ApiCount--;
         ApiCountChanged?.Invoke();
     }
    
     public bool IsShowOverlay
     {
         get { return ApiCount > 0 ? true : false; }
         set { }
     }
    

    }

  2. inject the ApiService to the client program.cs. I inject all Service classes by this foreach loop:

    // Singleton AppServices var appServices = typeof(Program).Assembly.GetTypes() .Where(s => s.Name.EndsWith("Service") && s.IsInterface == false).ToList(); foreach (var appService in appServices) builder.Services.Add(new ServiceDescriptor(appService, appService, ServiceLifetime.Singleton));

3.Add a method for HandleApiCountChanged event handler in MainLayout.razor.cs:

protected override async Task OnInitializedAsync()
{
    ApiService.ApiCountChanged += HandleApiCountChanged;
}
private void HandleApiCountChanged()
{
    StateHasChanged();
}
  1. Add MudOverlay to the MainLayout.razor inside MudLayout tag, its visibility is binded to the ApiService.IsShowOverlay bool method.

    <MudOverlay ZIndex="9999" @bind-Visible="ApiService.IsShowOverlay" DarkBackground="true" AutoClose="false">

  2. inject ApiService to the HttpStatusCodeService client api meddleware and IncreaseApiCount() , DecreaseApiCount of ApiService before and after api calling, MainLayout read the IsShowOverlay() method and show and hide the MudOverlay in all application api callings.

public class HttpStatusCodeService(ISnackbar snackbar, LocalStorageService localStorageService, AuthenticationStateProvider authenticationStateProvider, ApiService apiService) : DelegatingHandler { protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { // before sending the request apiService.IncreaseApiCount(); // Increase Api Count if (request.RequestUri.AbsolutePath.ToLower().Contains("loginregister")) return await base.SendAsync(request, cancellationToken);

    var token = await localStorageService.GetItem<string>("token");
    if (string.IsNullOrEmpty(token) == false)
        request.Headers.Add("Authorization", $"Bearer {token}");

    var response = await base.SendAsync(request, cancellationToken);


    // after sending the request
    if (response.IsSuccessStatusCode == false)
    {
        if (response.StatusCode == HttpStatusCode.Conflict) // 409
            snackbar.Add(await response.Content.ReadAsStringAsync(), Severity.Warning);

        if (response.StatusCode == HttpStatusCode.InternalServerError) // 500
            snackbar.Add("A problem is occured, tell out support team.", Severity.Error);

        if (response.StatusCode == HttpStatusCode.Forbidden) // 403
            snackbar.Add("You don't have permission.", Severity.Warning);

        if (response.StatusCode == HttpStatusCode.Unauthorized) // 401
            await (authenticationStateProvider as AuthStateProvider).Logout(forceLoad: true);
    }
    apiService.DecreaseApiCount(); // Decrease Api Count
    return response;
}

}

I can inject without private readonly property and constructor by primary cunstructor feature in .net 8. for using it you must add "LangVersion>preview/LangVersion>" to PropertyGroup of client csproj file.

Upvotes: 0

Alexey
Alexey

Reputation: 1

Easy to use at net7:

  • create model SpinnerModel.cs

     Class SpinnerModel
        Public bool IsShow {get set}
        // add two actions to set property to true and false
    
  • create razor component Spinner.razor

    • add some block with information about waiting
    • add created model as [parameter] to block @code
    • wrap this markup into @if-statement to check property of model

At your some view, add and init field of spinner-model, add the component of spinner to markup and bind field as model parameter at spinner-component.

And them you can set property of field to true and false (or invoke added actions to change binded property) at handlers (for example, at button-click handlers before and after async operation)

It does not working with "oninitialize", but works very well at another custom handlers.

And you can not use some strange code (for example, wraping async operations to "Task.Run")

P.s. sorry, i wrote it via mobile

Upvotes: -1

dani herrera
dani herrera

Reputation: 51705

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

Upvotes: 74

Daniyal Mahmood
Daniyal Mahmood

Reputation: 11

use InvokeAsync(StateHasChanged), hopefully it will work.

protected async void Submit()
    {
        //Show Sending...
        cursorCSS = "";
        this.StateHasChanged();
        response = Service.Post(userModel);
        if (response.Errors.Any())
        {
            errorCss = "errorOn";
        }
        //turn sending off
        cursorCSS = "cursorSpinOff";
        await InvokeAsync(StateHasChanged);
    }

Upvotes: 0

Henk Holterman
Henk Holterman

Reputation: 273464

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);
}

Upvotes: 12

Lancelot Lovejoy
Lancelot Lovejoy

Reputation: 329

Blazor Serverside - I needed to call StateHasChanged() to force the frontend to update so the spinner would show before the code moves onto the ajax call.

/* Show spinner */
carForm.ShowSpinner = true;

/* Force update of front end */
StateHasChanged();

/* Start long running API/Db call */
await _carRepository.Update(item);

Upvotes: -1

PepperTiger
PepperTiger

Reputation: 604

To answer the notice in @daniherrera's solution, there is three more elegant solution proposed here.

In short :

  • Implement INotifyPropertyChanged to the Model and invoke StateHasChanged() on a PropertyChangedEventHandler event property from the Model.
  • Use delegates to invoke StateHasChanged() on the Model.
  • Add a EventCallBack<T> parameter to the component or page of the View and assign it to the function that should change the render of the component and their parents. (StateHasChanged() isn't necessary in this one`)

The last option is the most simple, flexible and high level, but choose at your convenience.

Overall, I'll advise to use one of those solutions presented more than the await Task.Delay(1); one if security of your app is a concern.

Edit : After more reading, this link provide a strong explanation on how to handle events in C#, mostly with EventCallBack.

Upvotes: 2

albin
albin

Reputation: 655

Don't do the same mistake as I did by testing wait spinner using Thread.Sleep(n).

protected override async Task OnInitializedAsync()
{
    // Thread.Sleep(3000); // By suspending current thread the browser will freeze.
    await Task.Delay(3000); // This is your friend as dani herrera pointed out. 
                      // It creates a new task that completes 
                      // after a specified number of milliseconds.

    forecasts = await ForecastService.GetForecastAsync(DateTime.Now);
}

Upvotes: 1

Related Questions