How to do loading spinners, the Angular 2+ way.

This post walks you through how to build your own spinner management service from scratch. It is almost identical to what I have already built and packaged in the @chevtek/angular-spinners package on npm.

How to do loading spinners, the Angular 2+ way.
Note: This post walks you through how to build your own spinner management service from scratch. It is almost identical to what I have already built and packaged in the angular-spinners package on npm. If all you want is access to the angular-spinners package then you can find it here. There is also a working Plunker demo here.

A few years ago I wrote a post about how to do loading spinners the Angular way. At the time I was inspired by the fact that I kept seeing people struggle with the same problem. When you have a complex application with tons of components and all you want to do is toggle a loading graphic in one component half way across your app, what's the best way to do it?

I've seen some of the craziest solutions for solving this problem. You'd think simply showing/hiding a graphic or CSS animation would be easy, but sometimes apps are so complex that developers get lost trying to concoct the best solution. The most common issue is trying to toggle a loading spinner at just the right time, oftentimes from within a service that isn't directly related to a component. I've seen colleagues store global references or broadcast an event from Angular 1's $rootScope just to tell one little spinner somewhere to show itself. Talk about overkill.

Often we have a simple scenario where we click a button to begin some operation and showing a loading graphic is easy and straightforward.

<!-- some.component.html -->

<button (click)="doAThing()">Do A Thing</button>
<img src="path/to/animated/loading.gif" *ngIf="loading" />
// some.component.ts

@Component(...)
export class SomeComponent {
  loading: boolean = false;

  constructor(private myService: MyService) {}

  doAThing(): void {
    this.loading = true;
    this.myService.doTheThing().then(() => {
      this.loading = false;
    });
  }

In most cases this is simple and straightforward, but in large complex applications there are a lot of interactions going on. What if you want to show/hide a spinner in a separate component tree that isn't managed by this component. Do you really want to have to broadcast some event or create some type of message bus just to show/hide a spinner somewhere else in your app? What if you want to hide/show spinner(s) from within a service? What about hiding/showing multiple loading spinners? What if you want to hide every loading spinner on the page in the event of an error so as to prevent hanging loading graphics? I created this library so I could stop having to think through those complex scenarios so often.


The Component

Of course we're going to need a component to house our loading spinner so our users can just quickly place the component down and forget about it. We want this component to perform a bit of behind the scenes magic in order to make the user's life easier. We'd like the component to register itself with a service that can track and maintain our spinners. We'll go into more detail on that service further down the page, but let's just keep that future service in mind when we craft the inputs for our component.

Let's stub out a basic spinner component real quick.

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

@Component({
  selector: 'spinner',
  template: `
    <img [src]="loadingImage" />
  `
})
export class SpinnerComponent {}

What we have so far is very basic. You can see our template expects us to supply a path to a loadingImage so lets go ahead and add that in as our first input.

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

@Component(...)
export class SpinnerComponent {
  @Input() loadingImage: string;
}

By declaring our loadingImage property with the @Input() decorator we make it possible for users of our component to pass in a value for it. Example:

<spinner loadingImage="path/to/loading.gif"></spinner>

So far we've done nothing special. In fact all we've done is superfluously wrap a lonely <img> tag in a component when we could have just used an <img> tag directly! Don't worry, we'll get there. We know that we are going to need to hide and show this directive so let's go ahead and add an input for that.

@Component({
  selector: 'spinner',
  template: `
    <img [src]="loadingImage" *ngIf="show" />
  `
})
export class SpinnerComponent {
  @Input() loadingImage: string;
  @Input() show = false;
}

Now we've added an *ngIf directive to our <img> element so the user can show it or hide it by changing the show input property on our component's class. For example, to show the spinner by default the user could simply pass in true: <spinner loadingImage="path/to/loading.gif" [show]="true"></spinner>. They could just as easily pass in a variable from the parent component so they can modify that variable at will to hide or show the spinner.

Before we move on we should figure out how to make our loadingImage required because without it our component is useless as it is now. There is no built-in mechanism to declare an input property as required. This is probably because implementing it manually is pretty easy. There are different component lifecycle hooks we can implement and tap into. One of which is OnInit. The OnInit function is called after inputs have been initialized on components that implement the OnInit interface.

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

@Component(...)
export class SpinnerComponent implements OnInit {
  @Input() loadingImage: string;
  @Input() show = false;

  ngOnInit(): void {
    if (!this.loadingImage) throw new Error("Spinner must have a loadingImage supplied.");
  }
}

After declaring that our component implements the OnInit interface we then have to actually implement it by creating an ngOnInit method on our class. In this method we can check for values of our input properties and throw an error if a required property is not populated. You might be wondering why we didn't just do this check in the class constructor. The reason is because when the constructor runs the input properties have not yet been assigned. OnInit is the first place in the component lifecycle that we can expect those values to be present.

At this stage it's time to revisit a feature from the old Angular 1.x spinner directive: transclusion. Transclusion is a silly name that no longer exists in Angular 2 or higher. Instead the transclusion concept from Angular 1 is exposed in Angular 2 under a new name: content projection. Despite the name change the concept is the same. Our old spinner would allow the user to pass in HTML markup instead of a loadingImage so they could have full control over the look and style of their spinner. It was a little complicated to do transclusion in Angular 1 but in Angular 2 it's easy. Simply add <ng-content></ng-content> into your component's template. That's it.

To add this to our spinner component we'll also want to wrap both <ng-content> and our <img> tag in a <div> and move our *ngIf to the outer <div>. That way we hide/show the entire component based on our show input property rather than just the <img> element.

@Component({
  selector: 'spinner',
  template: `
    <div *ngIf="show">
      <img [src]="loadingImage" />
      <ng-content></ng-content>
    </div>
  `
});

Now if the user chooses to they can omit loadingImage and instead pass in their own markup.

<spinner>
  <h1>Loading...</h1>
</spinner>

Except they can't omit loadingImage because we are checking to make sure it's required! Go ahead and remove that check from ngOnInit() but leave the method there because we'll use it later. Then add a check for loadingImage on the <img> tag so it is hidden if the user did not supply one.

template: `
  <div *ngIf="show">
    <img *ngIf="loadingImage" [src]="loadingImage" />
    <ng-content></ng-content>
  </div>
`

So far we've done some fun stuff but still nothing special. To have any control over this spinner at all the user still has to pass a variable into our show input property. The whole point of this library was to make it so the user didn't have to have a reference to the actual spinner component or its show property, so how do we do that?

First we will need to add a new input property called name. This property will be used to uniquely identify the spinner when it's being tracked by our spinner service that we'll get to shortly. This property will be required so go ahead and perform the same check we did earlier for loadingImage but for name instead.

@Component(...)
export class SpinnerComponent implements OnInit {
  @Input() name: string;
  @Input() loadingImage: string;
  @Input() show = false;

  ngOnInit(): void {
    if (!this.name) throw new Error("Spinner must have a 'name' attribute.");
  }
}

Before we move onto our spinner service let's go ahead and inject our service into our component and use it to register our spinner with the service. Of course it won't work yet since we haven't written the service yet, but it will give you a better idea of what the spinner service will be used for.

...
import { SpinnerService } from './spinner.service';

@Component(...)
export class SpinnerComponent implements OnInit {
  @Input() name: string;
  @Input() loadingImage: string;
  @Input() show = false;

  constructor(private spinnerService: SpinnerService) {}

  ngOnInit(): void {
    if (!this.name) throw new Error("Spinner must have a 'name' attribute.");

    this.spinnerService._register(this);
  }
}

We inject the SpinnerService into our component by asking for it via the constructor. By specifying private in front of the constructor argument Typescript will automatically apply it as a private property on our class, making it accessible in our ngOnInit method via this.spinnerService.

Notice that we call _register(this) on our SpinnerService. The _register method is prefixed with an underscore because it's not meant to be a commonly used method on the service by the user. It's there if they ever want to tinker with it but it is meant to be used by the SpinnerComponent to register itself with the service.


The Service

We saw above when we built our component that we depend on an instance of a SpinnerService. Luckily this part is super straightforward. The only thing we need from @angular/core here is the Injectable annotation because it's what tells Angular that our class can be used for dependency injection. So when our SpinnerComponent asks for an instance of SpinnerService in its constructor Angular will know what to supply.

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

@Injectable()
export class SpinnerService {}

Now that we have our class we can start filling it out. The first thing we will need is a place to store our spinners that register themselves with the service. In the old Angular 1.x spinner library we just used a simple JavaScript object as a cache to store our registered spinners. We could do that again or use an array, but we have a cooler option available to us since we're using Typescript: a Set. We only ever want a single instance of each SpinnerComponent to be registered with our service and it just so happens that a Set is guaranteed to be unique!

import { Injectable } from '@angular/core';
import { SpinnerComponent } from './spinner.component';

@Injectable()
export class SpinnerService {
  private spinnerCache = new Set<SpinnerComponent>();
}

By using generics we are able to tell Typescript that we expect our Set to be populated with instances of SpinnerComponent. Now let's go ahead and create that _register method we already know we are going to need.

@Injectable()
export class SpinnerService {
  private spinnerCache = new Set<SpinnerComponent>();

  _register(spinner: SpinnerComponent): void {
    this.spinnerCache.add(spinner);
  }

Now spinner components can easily register themselves with the spinner service, but that's only half of what we want the spinner service to do. We also want it to act as an API that the user can use to control the spinners it has stored in its cache. To do that lets add a couple show and hide methods to it.

@Injectable()
export class SpinnerService {
  private spinnerCache = new Set<SpinnerComponent>();

  _register(...): void { ... }

  show(spinnerName: string): void {
    this.spinnerCache.forEach(spinner => {
      if (spinner.name === spinnerName) {
        spinner.show = true;
      }
    });
  }

  hide(spinnerName: string): void {
    this.spinnerCache.forEach(spinner => {
      if (spinner.name === spinnerName) {
        spinner.show = false;
      }
    });
  }
}

We are finally to the point that our spinner component and service will actually work! The user can now drop a spinner component anywhere in his app (<spinner name="mySpinner">Loading...</spinner>) and then inject the spinner service to control it:

import { Injectable } from '@angular/core';
import { Http } from '@angular/http';
import 'rxjs/add/operator/toPromise';

import { SpinnerService } from './spinner.service';

@Injectable
export class MyService {
  constructor(
    private spinnerService: SpinnerService,
    private http: Http
  ) {}

  doTheThing(): void {
    this.spinnerService.show('mySpinner');
    this.http
      .post('/api/doThing')
      .toPromise()
      .then(response => {
        this.spinnerService.hide('mySpinner');
        // do stuff with response
      });
  }
}

We're almost done here but there are a few features we left out. The first is that the old spinner directive had the ability to specify a group name on each spinner and the service exposed methods for hiding and showing entire groups of spinners. Let's add that now.

// spinner.component.ts

@Component(...)
export class SpinnerComponent implements OnInit {
  @Input() name: string;
  @Input() group: string;
  @Input() loadingImage: string;
  @Input() show = false;

  constructor(private spinnerService: SpinnerService) {}

  ngOnInit(): void {
    if (!this.name) throw new Error("Spinner must have a 'name' attribute.");

    this.spinnerService._register(this);
  }
}
// spinner.service.ts

@Injectable()
export class SpinnerService {
  ...

  showGroup(spinnerGroup: string): void {
    this.spinnerCache.forEach(spinner => {
      if (spinner.group === spinnerGroup) {
        spinner.show = true;
      }
    });
  }

  hideGroup(spinnerGroup: string): void {
    this.spinnerCache.forEach(spinner => {
      if (spinner.group === spinnerGroup) {
        spinner.show = false;
      }
    });
  }
}

While we're at it why don't we go ahead and add some showAll() and hideAll() methods to our service.

@Injectable()
export class SpinnerService {
  ...

  showAll(): void {
    this.spinnerCache.forEach(spinner => spinner.show = true);
  }

  hideAll(): void {
    this.spinnerCache.forEach(spinner => spinner.show = false);
  }
}

We should also probably add a method to detect if a spinner is currently visible or not in case the user ever needs that information and faces the same hurdles of propagating that show variable all across their app.

@Injectable()
export class SpinnerService {
  ...

  isShowing(spinnerName: string): boolean | undefined {
    let showing = undefined;
    this.spinnerCache.forEach(spinner => {
      if (spinner.name === spinnerName) {
        showing = spinner.show;
      }
    });
    return showing;
  }
}

Notice the return value for isShowing is either boolean or undefined. I want to allow undefined as a return value in the event that there is no spinner with the specified name.

There is one last thing we need to add to our service. We need an _unregister method. Likewise, we need to handle the OnDestroy lifecycle hook on our component so that if/when Angular winds up destroying the component housing our spinner, the spinner will unregister itself from the service.

// spinner.service.ts

@Injectable()
export class SpinnerService {
  ...

  _unregister(spinnerToRemove: SpinnerComponent): void {
    this.spinnerCache.forEach(spinner => {
      if (spinner === spinnerToRemove) {
        this.spinnerCache.delete(spinner);
      }
    });
  }
}
// spinner.component.ts

import { Component, Input, OnInit, OnDestroy } from '@angular/core';
...

@Component(...)
export class SpinnerComponent implements OnInit, OnDestroy {
  ...

  ngOnDestroy(): void {
    this.spinnerService._unregister(this);
  }
}

Now if our SpinnerComponent or any of its parent components get garbage collected by angular our spinner will automatically unregister itself from the spinner service.

We are just about finished up but there is one last problem we've introduced and have not solved. Remember how we gave the user the ability to bind to our show input property?

<spinner name="mySpinner" [show]="showSpinner"></spinner>

That is a one-way bound variable. Meaning if the user modifies showSpinner in their own app our spinner component will pick up those changes. However, we have now introduced a way for the internal show property of our SpinnerComponent to get changed outside of that one-way binding. By calling spinnerService.hide('mySpinner') the user can hide that specific spinner. The problem is that change to our show property will not be propagated back to the user's showSpinner variable they passed in. So the user's code and our component's code could potentially get out of sync with each other. To solve this we need to implement two-way binding for our show property.

For the user to implement two-way binding, it's easy.

<spinner name="mySpinner [(show)]="showSpinner"></spinner>

By using [(show)] the user would expect any internal changes to the spinner's show property to propagate out to their own showSpinner variable. However, our spinner component does not currently support this. To support two-way binding we first need to understand what [(show)] actually does. It is shorthand for a slightly larger operation. The first part is simple to understand because it works the same as before with one-way binding. Any changes to showSpinner in the user's component will be passed along to our component's show property.

The parenthesis in [(show)] represent event binding. The actual event name for that property would be showChange but Angular hides that from the user by handling the showChange event and propagating the updates out and reassigning the updated value to the user's own showSpinner variable. You can visit the documentation for more details on two-way binding. The bottom line is that we need an input property for show and an output event called showChange. To do this we need to split up our @Input() show property into a separate getter and setter.

import { Component, Input, Output, EventEmitter, OnInit, OnDestroy } from '@angular/core';
...

@Component(...)
export class SpinnerComponent implements OnInit, OnDestroy {
  ...

  private isShowing = false;

  @Input()
  get show(): boolean {
    return this.isShowing;
  }

  @Output() showChange = new EventEmitter();

  set show(val: boolean) {
    this.isShowing = val;
    this.showChange.emit(this.isShowing);
  }
}

It might look a bit complicated but it's not actually that bad. Basically whenever the setter for show gets called we emit a showChange event with the current value. When the user does [(show)] it's just syntactic sugar provided by Angular so the user doesn't even have to think about two-way data binding very much. Whatever they pass in updates show on the spinner component and any time the show property is set the component emits a showChange event. Angular handles that showChange event behind the scenes and then assigns the value from that event back to the user's showSpinner variable they supplied.

The following two lines are equivalent:

<spinner name="mySpinner" [(show)]="spinnerShowing"></spinner>
<spinner name="mySpinner" [show]="spinnerShowing" (showChange)="spinnerShowing=$event"></spinner>

$event corresponds to the payload passed to .emit on the event emitter.

As you can see the user could choose to just one-way bind to the show input property and manually handle the showChange event if they wanted to. I think it's safe to say Angular's syntactic sugar is preferred in most cases.

Now that two-way data binding is implemented the user's showSpinner variable will get updated even if the spinner's show property was updated elsewhere such as from the spinner service :D