DEV Community

John Carroll
John Carroll

Posted on • Edited on

Angular: How to easily display loading indicators

A common task for Angular applications is showing a loading indicator during page navigation, or showing a loading indicator while POSTing data to a server, or showing a loading indicator while *something* is happening.

I recently read a great post by Nils Mehlhorn talking about various strategies that you can use to display/manage loading indicators. It's a great read, but I don't think any of the options discussed are quite as nice as using the small IsLoadingService I made for this task. Allow me to demonstrate using some examples which build off each other (some of the examples are taken from Nils' post).

Indicating that a page is loading

Let's start with page loading. Many (most?) Angular application's will make use of the Angular Router to allow navigation between sections of an app. We want to display a loading indicator while navigation is pending.

For our loading indicator example, we will use the MatProgressBar component from the @angular/material library, and display it along the top of the page.

@Component({
  selector: 'app-root',
  template: `
    <mat-progress-bar
      *ngIf="isLoading | async"
      mode="indeterminate"
      color="warn"
      style="position: absolute; top: 0; z-index: 5000;"
    >
    </mat-progress-bar>

    <router-outlet></router-outlet>
  `,
})
export class AppComponent {
  isLoading: Observable<boolean>;

  constructor(
    private isLoadingService: IsLoadingService,
    private router: Router,
  ) {}

  ngOnInit() {
    this.isLoading = this.isLoadingService.isLoading$();

    this.router.events
      .pipe(
        filter(
          event =>
            event instanceof NavigationStart ||
            event instanceof NavigationEnd ||
            event instanceof NavigationCancel ||
            event instanceof NavigationError,
        ),
      )
      .subscribe(event => {
        // If it's the start of navigation, `add()` a loading indicator
        if (event instanceof NavigationStart) {
          this.isLoadingService.add();
          return;
        }

        // Else navigation has ended, so `remove()` a loading indicator
        this.isLoadingService.remove();
      });
  }
}
Enter fullscreen mode Exit fullscreen mode

In this example, we use the IsLoadingService to get our loading state as an observable, save it in the class property isLoading, and subscribe to it in our root component's template. When loading is false, the loading indicator will disappear.

The Angular IsLoadingService is a small (less than 1kb minzipped) service for angular that helps us track whether "something" is loading. We can subscribe to loading state via IsLoadingService#isLoading$() which returns an Observable<boolean> value indicating if the state is loading or not.

  • To indicate that something has started loading, we can call IsLoadingService#add().
  • When something has stopped loading, we can call IsLoadingService#remove().
  • IsLoadingService will emit true from isLoading$() so long as there are one or more things that are still loading.

Because we want to display a loading bar during router navigation, we subscribe to angular Router events. When a NavigationStart event is emitted, we say that loading has started (via add()). When NavigationEnd || NavigationCancel ||NavigationError event is emitted, we say that loading has stopped (via remove()).

With this code, our app will now display a loading bar at the top of the page while navigating between routes.

  • Note: if we called IsLoadingService#add() with a Subscription, Promise, or Observable value, we wouldn't need to call remove().
    • When passed a Subscription or Promise, add() will automatically mark that the Subcription or Promise has stopped loading when it completes.
    • In the case of an Observable value, add() will take(1) from the observable and subscribe to it, noting that loading for that Observable has stopped when it emits it's next value.
    • More on this below...

Waiting for data

Having completed the task of adding a "page loading" indicator during router navigation, now we want to display a loading indicator when we asynchronously fetch a list of users from a service. We decide we want to trigger the same progress bar indicator that we just set up to indicate that a page is loading.

@Component({
  selector: 'user-component'
  template: `
    <list-component>
      <profile-component *ngFor="let user of users | async" [user]='user'>
      </profile-component>
    </list-component>
  `
})
export class UserComponent implements OnInit  {
  users: Observable<User[]>;

  constructor(
    private userService: UserService,
    private isLoadingService: IsLoadingService,
  ) {}

  ngOnInit() {
    this.users =
      this.isLoadingService.add( this.userService.getAll() );
  }
}
Enter fullscreen mode Exit fullscreen mode

Note how, in this example, there are no additional subscriptions or variables we need to manage.

When you call IsLoadingService#add() with an Observable (or Promise or Subscription) argument, that argument is returned.

So this code...

this.users = this.isLoadingService.add(this.userService.getAll());
Enter fullscreen mode Exit fullscreen mode

Is the same as this code, so far as this.users is concerned.

this.users = this.userService.getAll();
Enter fullscreen mode Exit fullscreen mode

Seperately (as mentioned before), when IsLoadingService#add() is passed an Observable value, IsLoadingService will take(1) from the value and subscribe to the results. When this component is initialized, this.isLoadingService.add( this.userService.getAll() ) will be called triggering the "page loading" indicator we set up previously. When the observable returned from this.userService.getAll() emits for the first time, IsLoadingService will know that this observable has stopped loading and update the "page loading" indicator as appropriate.

If this.userService.getAll() returned a promise (or subscription), we could also pass it to this.isLoadingService.add() and achieve similar results.

Submitting a form

Next up, we want to let our users create a new User by submitting a user creation form. When we do this, we'd like to disable the form's "submit" button and style it to indicate that the form is pending. We also decide that, in this case, we do not want to display the same "page loading" progress bar as before.

One way of accomplishing this task, is the following...

@Component({
  selector: 'user-component'
  template: `
    <form [formGroup]='userForm' novalidate>
      <mat-form-field>
        <mat-label> Your name </mat-label>
        <input matInput formControlName='name' required />
      </mat-form-field>

      <button
        mat-button
        color='primary'
        [disabled]='userFormIsPending | async'
        (click)='submit()'
      >
        Submit
      </button>
    </form>
  `,
  styles: [`
    .mat-button[disabled] {
      // button pending styles...
    }
  `]
})
export class UserComponent implements OnInit  {
  userForm: FormGroup;
  userFormIsPending: Observable<boolean>;

  constructor(
    private userService: UserService,
    private isLoadingService: IsLoadingService,
    private fb: FormBuilder,
    private router: Router,
  ) {}

  ngOnInit() {
    this.userForm = this.fb.group({
      name: ['', Validators.required],
    });

    this.userFormIsPending =
      this.isLoadingService.isLoading$({ key: 'create-user' });
  }

  async submit() {
    if (this.userForm.invalid) return;

    const response = await this.isLoadingService.add(
      this.usersService.createUser(
        this.userForm.value
      ).toPromise(),
      { key: 'create-user' }
    )

    if (response.success) {
      this.router.navigate(['user', response.data.userId, 'profile']);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

There are some new concepts here. For one, instead of subscribing to "default" loading state, we're subscribing to the "create-user" loading state by passing the key option to IsLoadingService#isLoading$(). In this case, userFormIsPending will ignore the page loading indicator state. It only cares about loading things added using add({key: 'create-user'}).

Next, when our userForm is submitted, we pass the form value to UsersService#createUser() which returns an observable. We transform that observable into a Promise, and await the result. We also pass this promise to IsLoadingService#add() with the key: 'create-user'.

If our mutation is a success, we navigate to the new user's profile page.

In the component template, we subscribe to userFormIsPending and, when a submit() is pending, the "submit" button is automatically disabled.

This example is pretty clean, but it still has this otherwise unnecessary userFormIsPending property. We can improve things...

Simplifying form submission with IsLoadingPipe

A quick way we can simplify the previous example is using the optional IsLoadingPipe alongside IsLoadingService. The IsLoadingPipe simplifies the task of subscribing to loading state inside a component's template.

The IsLoadingPipe is an angular pipe which recieves a key argument and returns an isLoading$({key}) observable for that key. Importantly, it plays nice with Angular's change detection. Because the IsLoadingPipe returns an observable, you should use it with the Anguilar built-in AsyncPipe.

@Component({
  selector: 'user-component'
  template: `
    <form [formGroup]='userForm' novalidate>
      <mat-form-field>
        <mat-label> Your name </mat-label>
        <input matInput formControlName='name' required />
      </mat-form-field>

      <button
        mat-button
        color='primary'
        [disabled]='"create-user" | swIsLoading | async' <!-- change is here -->
        (click)='submit()'
      >
        Submit
      </button>
    </form>
  `,
  styles: [`
    .mat-button[disabled] {
      // button pending styles...
    }
  `]
})
export class UserComponent implements OnInit  {
  userForm: FormGroup;

  constructor(
    private userService: UserService,
    private isLoadingService: IsLoadingService,
    private fb: FormBuilder,
    private router: Router,
  ) {}

  ngOnInit() {
    this.userForm = this.fb.group({
      name: ['', Validators.required],
    });
  }

  async submit() {
    if (this.userForm.invalid) return;

    const response = await this.isLoadingService.add(
      this.usersService.createUser(
        this.userForm.value
      ).toPromise(),
      { key: 'create-user' }
    )

    if (response.success) {
      this.router.navigate(['user', response.data.userId, 'profile']);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice, we no longer need to have the userFormIsPending property on our component.

However, we can improve this example further. In addition to simply disabling the userForm "submit" button, we'd like to add an "is-loading" css class to the button during loading. We'd also like to add an animated css spinner to the button while it is loading.

Improving form submission with IsLoadingDirective

Using the optional IsLoadingDirective alongside IsLoadingService, we can improve our previous example. The IsLoadingDirective allows us to easily style (and optionally disable) elements based on loading state.

@Component({
  selector: 'user-component'
  template: `
    <form [formGroup]='userForm' novalidate>
      <mat-form-field>
        <mat-label> Your name </mat-label>
        <input matInput formControlName='name' required />
      </mat-form-field>

      <button
        mat-button
        color='primary'
        swIsLoading='create-user' <!-- change is here -->
        (click)='submit()'
      >
        Submit
      </button>
    </form>
  `,
  styles: [`
    .sw-is-loading {
      // button styles...
    }

    .sw-is-loading .sw-is-loading-spinner {
      // spinner styles
    }
  `]
})
export class UserComponent implements OnInit  {
  userForm: FormGroup;

  constructor(
    private userService: UserService,
    private isLoadingService: IsLoadingService,
    private fb: FormBuilder,
    private router: Router,
  ) {}

  ngOnInit() {
    this.userForm = this.fb.group({
      name: ['', Validators.required],
    });
  }

  async submit() {
    if (this.userForm.invalid) return;

    const response = await this.isLoadingService.add(
      this.usersService.createUser(
        this.userForm.value
      ).toPromise(),
      { key: 'create-user' }
    )

    if (response.success) {
      this.router.navigate(['user', response.data.userId, 'profile']);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

In this example, we apply the IsLoadingDirective to our "submit" button via swIsLoading='create-user'. We pass the 'create-user' key to the directive, telling it to subscribe to "create-user" loading state.

When the "create-user" state is loading, the IsLoadingDirective will automatically disable the "submit" button and add a sw-is-loading css class to the button. When loading stops, the button will be enabled and the sw-is-loading css class will be removed.

Because this is a button/anchor element, the IsLoadingDirective will also add a sw-is-loading-spinner child element to the "submit" button.

<sw-is-loading-spinner class="sw-is-loading-spinner"></sw-is-loading-spinner>
Enter fullscreen mode Exit fullscreen mode

Using css, you can add a spinner animation (example here) to this element so that the button displays a pending animation during loading. All of these options are configurable. For example, if you don't want a spinner element added to buttons when they are loading, you can configure that globally by injecting new defaults or locally via swIsLoadingSpinner=false.

Triggering multiple loading indicators at once

As one last variation on the above, say we wanted to trigger both the page loading indicator as well as the "create-user" indicator during form submission. You can accomplish this by passing an array of keys to IsLoadingService#add().

In this case, you could update the previous example with...

export class UserComponent implements OnInit {
  async submit() {
    if (this.userForm.invalid) return;

    const response = await this.isLoadingService.add(
      this.usersService.createUser(this.userForm.value).toPromise(),
      { key: ['default', 'create-user'] }, // <-- change here
    );

    if (response.success) {
      this.router.navigate(['user', response.data.userId, 'profile']);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Calling IsLoadingService#isLoading$() without a key argument is the same as calling it with isLoading$({key: 'default'}), so you can trigger the "default" loading state by passing "default" as a key.

Conclusion

IsLoadingService has been created iteratively over the past year or so. At this point, I find that it greatly simplifies managing loading indicators in my apps.

You can check it out at: https://gitlab.com/service-work/is-loading

Top comments (17)

Collapse
 
stephen_haney_65559bb7cbc profile image
Stephen Haney

I am fairly new to Angular (coming from a .net C# background). I have been looking for a pretty clean loading pattern and yours looks like its well conceived and built. The one question I had is that I'd like to to manipulate the inner contents of sw-is-loading-spinner vs just doing a straight css solution. How would you recommend going about that? Would I just create a directive? Appreciate it!

Collapse
 
johncarroll profile image
John Carroll

Oh interesting. Are you saying that you'd like to provide your own component to act as the loading spinner? Currently that's not a supported feature, but I think it would be easy to add. If you open a feature request in the repo, I might be able to do it this week. I think all that would need to happen is update ISWIsLoadingDirectiveConfig to accept a spinner component class and then have IsLoadingDirective use that instead of the default IsLoadingSpinnerComponent.

Collapse
 
stephen_haney_65559bb7cbc profile image
Stephen Haney

Initially I was thinking it'd be nice to have it inject my own component as I want contemplating some more context-specific logic to adjust spinner size and animations to use if its a button (and then some logic branching if its an icon button vs non-icon) or just some random div. Perhaps the config could have an optional IsLoadingSpinnerComponent where I could just reference my own component.

I ended up getting 99% of the way there using CSS and just having very specific selector patterns to branch logic so its not a huge deal but I can enter a feature request in the repo. Thanks!

Thread Thread
 
johncarroll profile image
John Carroll

FYI in case you missed it, I replied to the feature request you opened with a followup question.

Collapse
 
mhagnumdw profile image
mhagnumdw

Hi! Another question. I'm trying to understand all of your logic...

If this.isLoadingService.add receive an Observable you subscribe to it, here. And the same input Observable is returned.

And in your example, you subscribe again within your component, here: <profile-component *ngFor="let user of users | async" [user]='user'>

The observable is executed twice. Or did I miss something? Can you explain me?

Tks!!

Collapse
 
johncarroll profile image
John Carroll

The observable is executed twice

Yup. The observable is subscribed to twice. Once by the isLoadingService, and once by the async pipe in the component's template.

Collapse
 
mhagnumdw profile image
mhagnumdw

If it is a heavy http request you will be doing this twice, unless you use a sharereplay(1) or a ReplaySubject(1). If it is a request to create a record...
Well, what I mean is that we may have a problem here, right?

Thanks again!

Thread Thread
 
johncarroll profile image
John Carroll

If it is a heavy http request you will be doing this twice, unless you use a shareReplay(1) or a ReplaySubject(1)

Depends on the observable.

Well, what I mean is that we may have a problem here, right?

I can't answer that for you. I'm not familiar with your project. If you're unsure how to use observables, you can always just stick to promises (or transform observables to promises with toPromise()).

I'll note that Angular's HttpClient returns observables which complete after the request resolves. If you're using it, this isn't something you need to worry about.

Collapse
 
cname87 profile image
cname87 • Edited

Very nice I'm using it and it's sweet.

BTW, it appears that an observable returned from fn, as passed in isLoadingService.add(fn), must be asynchronous i.e. in a fail mode I was passing 'of([])' instead of an httpClient observable and it didn't remove the loading indicator, whereas when I pass in 'scheduled(of([]), asapScheduler' it did.

Collapse
 
johncarroll profile image
John Carroll • Edited

Thanks! I'm pretty happy with how much it's cleaned up my code.

Also, that sounds like a bug. Can you open an issue in gitlab.com/service-work/is-loading ?

Collapse
 
cname87 profile image
cname87

Will do

Thread Thread
 
johncarroll profile image
John Carroll

Great bug report. Fixed in v3.0.2. gitlab.com/service-work/is-loading...

Collapse
 
mhagnumdw profile image
mhagnumdw • Edited

Great! Thanks for the detailed explanation!

A question, taking a snippet of your example here:

<button
    swIsLoading='create-user'
    (click)='submit()' >

It is possible, unintentionally, swIsLoading to have a key and the submit function to add a different key. Another thing, besides your strategy being very good, there is still an effort to define the same key in swIsLoading and in the submit function.

Have you ever thought of a directive called, for example, clickProgress that receives a function or an Observable? And nothing more than that. Do you think it is a good strategy?

[(clickProgress)]="arrowFunctionHere" key="create-user"

or

[(clickProgress)]="anyObservable" key="create-user"

Again, thank you very much!

Collapse
 
johncarroll profile image
John Carroll • Edited

It is possible, unintentionally, swIsLoading to have a key and the submit function to add a different key.

Is this an actual problem you are running into? This hasn't been a problem for me.

Another thing, besides your strategy being very good, there is still an effort to define the same key in swIsLoading and in the submit function.

No, defining the key twice is a feature, it's not simply an implementation detail. It's enforcing a separation of concerns. You're treating the submit() function like it's tied to the view when it's not. submit() can be called elsewhere and it still will trigger the "create-user" loading state. Similarly, the view's "create-user" loading state can also be triggered by other means (not just the submit() function).

Put another way, the button's loading indicator is triggered by the "create-user" loading state. It is not triggered by the submit() function (though, in this case, the submit() function happens to trigger the "create-user" loading state). Tying the button's loading state specifically to the submit() function would be undesirable.

Regardless, it sounds like you might be interested in the Angular Promise Buttons library, which has a less abstract button loading indicator solution that might appeal to you.

Collapse
 
mhagnumdw profile image
mhagnumdw

Thanks!!

Collapse
 
joellau profile image
Joel Lau

happened to have a similar use case at my workplace and derived a similar solution. glad to know i'm not coming up with something strange!

thanks for taking the time to write this

Collapse
 
alexandrejme1234 profile image
alexandre-jme1234

Hey John, thanks for your module it's really simple & useful.
Is it compatible with Angular 17?
Thks!!