Angular: Spinner Interceptor

bob.ts - Oct 25 '21 - - Dev Community

Basically, I needed a means to provide a spinner that blocked functionality while API calls were in-flight. Additionally, I wanted to take into account that there could be more than one API request in-flight, at one time.

Repository

A Failed Attempt

My first attempt was to use an interceptor service that contained a BehaviorSubject (Observable). I set it up to maintain a counter and set the observable's value to true if there were more than zero (0) requests in-flight.

Through heavy use of the console.log functionality, I came to realize that the interceptor was not always active, even though I was following proper singleton patterns.

Working Version

The second attempt went more smoothly.

I had a second service (a handler) that maintained the counts and the BehaviorSubject. This one worked "like a charm."

Spinner Interceptor Service

spinner-interceptor.service.ts

import { Injectable } from '@angular/core';
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http';
import { Observable } from 'rxjs';
import { finalize } from 'rxjs/operators';

import { SpinnerHandlerService } from './spinner-handler.service';

@Injectable()
export class SpinnerInterceptorService implements HttpInterceptor {

  constructor(
    public spinnerHandler: SpinnerHandlerService
  ) {}

  intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
    this.spinnerHandler.handleRequest('plus');
    return next
      .handle(request)
      .pipe(
        finalize(this.finalize.bind(this))
      );
  }

  finalize = (): void => this.spinnerHandler.handleRequest();

}
Enter fullscreen mode Exit fullscreen mode

Unit Tests ...

spinner-interceptor.service.spec.ts

import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';

import { SpinnerInterceptorService } from './spinner-interceptor.service';

import { SpinnerHandlerService } from './spinner-handler.service';

describe('SpinnerInterceptorInterceptor', () => {
  let service: SpinnerInterceptorService;

  beforeEach(async () => {
    TestBed.configureTestingModule({
      providers: [
        SpinnerInterceptorService,
        SpinnerHandlerService
      ]
    }).compileComponents();
  });

  beforeEach(() => {
    service = TestBed.inject(SpinnerInterceptorService);
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });

  it('expects "intercept" to fire handleRequest', (done: DoneFn) => {
    const handler: any = {
      handle: () => {
        return of(true);
      }
    };
    const request: any = {
      urlWithParams: '/api',
      clone: () => {
        return {};
      }
    };
    spyOn(service.spinnerHandler, 'handleRequest').and.stub();

    service.intercept(request, handler).subscribe(response => {
      expect(response).toBeTruthy();
      expect(service.spinnerHandler.handleRequest).toHaveBeenCalled();
      done();
    });
  });

  it('expects "finalize" to fire handleRequest', () => {
    spyOn(service.spinnerHandler, 'handleRequest').and.stub();

    service.finalize();
    expect(service.spinnerHandler.handleRequest).toHaveBeenCalled();
  });

});
Enter fullscreen mode Exit fullscreen mode

Spinner Handler Service

spinner-handler.service.ts

import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class SpinnerHandlerService {

  public numberOfRequests: number = 0;
  public showSpinner: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);

  handleRequest = (state: string = 'minus'): void => {
    this.numberOfRequests = (state === 'plus') ? this.numberOfRequests + 1 : this.numberOfRequests - 1;
    this.showSpinner.next(this.numberOfRequests > 0);
  };

}
Enter fullscreen mode Exit fullscreen mode

Spinner Component

spinner.component.ts

import { Component } from '@angular/core';

import { SpinnerHandlerService } from '@core/services/spinner-handler.service';

@Component({
  selector: 'spinner',
  templateUrl: './spinner.component.html',
  styleUrls: ['./spinner.component.scss']
})
export class SpinnerComponent {

  spinnerActive: boolean = true;

  constructor(
    public spinnerHandler: SpinnerHandlerService
  ) {
    this.spinnerHandler.showSpinner.subscribe(this.showSpinner.bind(this));
  }

  showSpinner = (state: boolean): void => {
    this.spinnerActive = state;
  };

}
Enter fullscreen mode Exit fullscreen mode

spinner.component.html

<div class="spinner-container" *ngIf="spinnerActive">
  <mat-spinner></mat-spinner>
</div>
Enter fullscreen mode Exit fullscreen mode

spinner.component.scss

.spinner-container {
  background-color: rgba(0,0,0, 0.1);
  position: fixed;
  left: 0;
  top: 0;
  height: 100vh;
  width: 100vw;

  display: flex;
  align-items: center;
  justify-content: center;

  z-index: 10000
}
Enter fullscreen mode Exit fullscreen mode

One More Thing

Don't forget to add the interceptor service into app.module.ts ...

providers: [
  { provide: HTTP_INTERCEPTORS, useClass: SpinnerInterceptorService, multi: true }
],
Enter fullscreen mode Exit fullscreen mode

Repository

Conclusion

This pattern is a reasonable one and the observable can be used in a variety of scenarios.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .