DEV Community

Stefanos Kouroupis
Stefanos Kouroupis

Posted on • Edited on

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

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

Enter fullscreen mode Exit fullscreen mode

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;
  }
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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

export enum HttpProgressState {
    start,
    end
}
Enter fullscreen mode Exit fullscreen mode

The single BehaviorSubject property service

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

  constructor() { }
}
Enter fullscreen mode Exit fullscreen mode

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
      });
    }));
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
    }
....
]
Enter fullscreen mode Exit fullscreen mode

missing interface (see comments)

export interface IHttpState {
    url: string;
    state: HttpProgressState;
}
Enter fullscreen mode Exit fullscreen mode

Top comments (7)

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

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

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

Collapse
 
tkaufmann profile image
t-kaufmann • Edited

Hi Stefanos,
you forgot to declare IHttpState.

Collapse
 
elasticrash profile image
Stefanos Kouroupis

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