loading...

Angular: display a spinner on any component that does an HTTP request

elasticrash profile image Stefanos Kouroupis Updated on ・3 min read

This is by definition one of the weirdest, useful and a bit ugly component I've written.

Our goal is to apply a spinner on top of any component that relies on an HTTP request

First we need to create a simple component that has it can take the size of its parent and has a spinner in the middle. I am using the Angular Material library to make things simpler.

This component is using a single service called HttpStateService. As we will see in a moment HttpStateService has only a single property of type BehaviorSubject. So its basically being used to pass messages back and forth.

So our component subscribes to any messages coming from that subject.
The spinner component also has an @Input() property which is on which url it should react.

@Component({
  selector: 'http-spinner',
  templateUrl: './spinner.component.html',
  styleUrls: ['./spinner.component.scss']
})
export class SpinnerComponent implements OnInit {
  public loading = false;
  @Input() public filterBy: string | null = null;
  constructor(private httpStateService: HttpStateService) { }

  /**
   * receives all HTTP requests and filters them by the filterBy
   * values provided
   */
  ngOnInit() {
    this.httpStateService.state.subscribe((progress: IHttpState) => {
      if (progress && progress.url) {
        if (!this.filterBy) {
          this.loading = (progress.state === HttpProgressState.start) ? true : false;
        } else if (progress.url.indexOf(this.filterBy) !== -1) {
          this.loading = (progress.state === HttpProgressState.start) ? true : false;
        }
      }
    });
  }
}

the css

.loading {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    background: rgba(0, 0, 0, 0.15);
    z-index: 1;
    display: flex;
    align-items: center;
    justify-content: center;
  }

and the html, in which we just either a) display the whole thing or b) not

<div *ngIf="loading" class="loading">
  <mat-spinner></mat-spinner>
</div>

our extremely simple HttpProgressState denoting
whether a request has started or ended

export enum HttpProgressState {
    start,
    end
}

The single BehaviorSubject property service

@Injectable({
  providedIn: 'root'
})
export class HttpStateService {
  public state = new BehaviorSubject<IHttpState>({} as IHttpState);

  constructor() { }
}

And now the most important bit, the HttpInterceptor. An HttpInterceptor is basically a man in the middle service that intercepts all requests that you might try to do through the HttpClientModule and manipulate them or react to them before they get fired. Here I have a relatively simple implementation of an HttpInterceptor. I've added take and delay to underline some powerful capabilities an HttpInterceptor might have.

Apart from take and delay, I've added one more and that is finalize.

So basically every time the InterceptorService intercepts a request it sends a message to the HttpStateService containing the url and a start state.
then on finalize (after the request has finished) sends an end state to the HttpStateService

@Injectable({
  providedIn: 'root'
})
export class InterceptorService implements HttpInterceptor {

  private exceptions: string[] = [
    'login'
  ];

  constructor(
    private httpStateService: HttpStateService) {

  }

  /**
   * Intercepts all requests
   * - in case of an error (network errors included) it repeats a request 3 times
   * - all other error can be handled an error specific case
   * and redirects into specific error pages if necessary
   *
   * There is an exception list for specific URL patterns that we don't want the application to act
   * automatically
   * 
   * The interceptor also reports back to the httpStateService when a certain requests started and ended 
   */
  intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {

    if (!this.exceptions.every((term: string) => request.url.indexOf(term) === -1)) {
      return next.handle(request).pipe(tap((response: any) => {},
     (error) => {}));
    }

    this.httpStateService.state.next({
      url: request.url,
      state: HttpProgressState.start
    });

    return next.handle(request).pipe(retryWhen(
      error => {
        return error.pipe(take(3), delay(1500),
          tap((response: any) => {
             // ...logic based on response type
             // i.e redirect on 403
             // or feed the error on a toaster etc
          })
        );
      }
    ), finalize(() => {
      this.httpStateService.state.next({
        url: request.url,
        state: HttpProgressState.end
      });
    }));
  }
}

Its usage is simple add it to any component that needs a spinner and define which endpoint it needs to listen to.

<http-spinner filterBy="data/products"></http-spinner>

Lastly to add an interceptor on a Module you just need to add another providers like the following example

  providers: [
    {
      provide: HTTP_INTERCEPTORS,
      useClass: InterceptorService,
      multi: true
    }
....
]

missing interface (see comments)

export interface IHttpState {
    url: string;
    state: HttpProgressState;
}

Discussion

pic
Editor guide
Collapse
maszeh profile image
Thalles

Hi Stefanos,

I really liked your solution, I have implemented similar code in my application, and in both your code and mine the same issue occurs. When I make an isolated call to the backend everything happens normal, but if two calls occur at the same time (at the initialization of a component for example) in the interceptor arrive both, but in the subscribe of the BehaviorSubject only one arrives. I'm really stuck at this, any thoughts why this happens?

Collapse
elasticrash profile image
Stefanos Kouroupis Author

I ve only used that component in tables and charts and ...even when I had multiple charts per page, that still works fine. Bare in mind that each char subscribed to a different endpoint. So I've never tested it with multiple requests per component.

Because I find the issue intriguing...I am going to spend sometime tomorrow investigating it.

Collapse
maszeh profile image
Thalles

I found the problem in my application, it was not a problem because there were multiple subscriptions, the problem is that I was making the backend call at the same time as the component creation happened (changing the route and opening a screen did both) and when I made that call my component had not subscribed to BehaviorSubject yet. I just moved my call to the backend to ngAfterViewInit and everything worked as it should.

Collapse
douglaslira profile image
Douglas Lira

This component have repository?

Collapse
elasticrash profile image
Stefanos Kouroupis Author

No it doesn't. It is too simple, in my opinion, to be turned into a lib.

Collapse
tkaufmann profile image
t-kaufmann

Hi Stefanos,
you forgot to declare IHttpState.

Collapse
elasticrash profile image
Stefanos Kouroupis Author

true, Ill add it in, I wonder how no one noticed it so far.