Angular 2+ HTTP Request - Show loading spinner for a minimum duration for both success and error responses

Viewed 1565

The requirement:

I need to show a loading spinner on UI each time when a specific HTTP request is made.

In order to have a nice visual aspect, I decided to show the spinner on screen for at least 1 second, even if the request lasts less (in fact, it lasts between 0.1s and 3-4 minutes, so better to hold the spinner for at least 1 second). So, the conditions are:

  • if the request takes less than 1s, the spinner will show for 1 second
  • if the request takes longer than 1s, the spinner will show till it finishes.

I know that this approach can be debatable from UI/UX perspective - but I prefer to consider it as a technical challenge.

The code I've tried:

As found on other implementation on SO, I've tried an approach with combineLatest - combine an Observable that takes 1s and the Observable for the http request.

load() {
  this.loading = true; // this will show the spinner
  combineLatest(timer(1000), this.service.apiCall())
    .pipe(
      finalize(()=> {
        this.loading = false; // this will hide the spinner
      }),
      map(x => x[1])
    )
    .subscribe(x => {
      console.log(x);
    });
}

This works well if the HTTP request returns with status 200.

The problem:

The code above does not work if the HTTP request returns an error (4/5xx). The Observables finish right after the HTTP request ends.

I want the spinner to have the same behavior even if the request finishes first, with an error.

I've made a simple Stackblitz, where we can play with different requests: https://stackblitz.com/edit/spinner-with-min-duration-zcp7hc

Thanks!

3 Answers

A more elegant and reactive way of doing it using simple merge.

load(miliseconds: number) {
  const showSpinner$ = of(true);
  const request$ = this.svc.apiCall(miliseconds).pipe(
    map(() => false), // disable spinner on successfull request
    catchError(() => of(false)), // disable spinner on request error
    finalize(() => false) // disable spinner when request observable finalize
  );

  this.loading$ = merge(showSpinner$, request$);
}

Stackblitz demo: https://stackblitz.com/edit/spinner-with-min-duration-4e9r2t?file

From rxjs documentation:

If at least one Observable was passed to combineLatest and all passed Observables emitted something, resulting Observable will complete when all combined streams complete. ... On the other hand, if any Observable errors, combineLatest will error immediately as well, and all other Observables will be unsubscribed.

So you have to handle your error causing observable with its own error catching routine using catchError pipeline, so that it couldn't throw an error to combineLatest operator. Something like this would work.

load() {
  this.loading = true; // this will show the spinner
  combineLatest(timer(1000), 
    this.service.apiCall().pipe(
        catchError(err)=>{
            return of(err); // Return observable wrapped with error.
        }))
    .pipe(
      finalize(()=> {
        this.loading = false; // this will hide the spinner
      }),
      map(x => x[1])
    )
    .subscribe(x => {
      console.log(x);
      //Check in the result if there is an error in http
      if(x instanceof HttpErrorResponse) {
           // do what you want in error scenario.
      }
    });
}

Stackblitz demo: https://stackblitz.com/edit/spinner-with-min-duration-ls9dq7

You can use a BlockUIModule, and provide a delayStop of 1 seconds.

@NgModule({
  declarations: [AppComponent],
  imports: [
    BlockUIModule.forRoot({
      delayStop: 1000,
      template: LoaderComponent
    })
  ],

This will work for success and error scenarios. Additionaly you can provide a template for custom message with a entry component say Loader Component

loader.component.ts

import { Component, OnInit } from "@angular/core";

@Component({
  selector: "ds-loader",
  templateUrl: "loader.component.html"
})
export class LoaderComponent implements OnInit {

  message: string;
  constructor() {}

  ngOnInit() {}
}

loader.component.html

    <div class="loader"></div>
        <p>{{message}}</p> 

To use it inside a component, eg:

@Component({
  selector: "ds-block-ui-search",
  templateUrl: "block-ui-search.component.html",
  animations: [onLoad],
  providers: []
})
export class BlockUiSearchComponent implements OnInit {

  @BlockUI() blockUI;


  ngOnInit() {


    this.blockUI.start("Loading...");

    this.blockUiSearchSearvice.getYourData().subscribe(
      res => {

        this.blockUI.stop();
        //Custom Logic

      },
      err => {
        this.blockUI.stop();

      }
    );
  }
}
Related