DEV Community

Cover image for Creating a loading effect using RxJs in Angular
Ayyash
Ayyash

Posted on • Originally published at garage.sekrab.com

Creating a loading effect using RxJs in Angular

Carrying on from creating our state management based on RxJS, today we will create an example usage: the loading effect. We will start with the basic state service and derive our loader states from it.

The ingredients:

  • a UI element to represent the visual "loading" effect
  • a loading state service to represent its current state

The scenarios we shall dive into:

  • End of an Http call
  • Multiple loaders
  • End of pagination
  • Multiple instances of the same loader
  • Concurrent Http calls

Find the final code on StackBlitz

Http requests loading effect

We will begin with the simplest and most required scenario, a single loader throughout the whole application that represents the loading effect of an Http call. Let's create our component, and dump it in the root. (The style of this bar is---ahem---borrowed from material website: Progress bar. and showered and cleaned up, and dressed properly.)

 // in components/common/load.partial.ts
@Component({
    selector: 'http-loader',
    template: `<div class="httploader">
    <div class="line"></div>
    <div class="subline inc"></div>
    <div class="subline dec"></div></div>`,
    styleUrls: ['./loader.css'],
    standalone: true,
    imports: [NgIf] // we probably just need this
})
Enter fullscreen mode Exit fullscreen mode

The css is straightforward, you can find it on StackBlitz. Since this is a standalone component, we need to import it to the AppModule to be able to use it in the app root component.

// app.module
@NgModule({
  // import component
  imports: [BrowserModule, LoaderComponent],
  declarations: [AppComponent],
  bootstrap: [AppComponent],
})
export class AppModule {}

// app.component.html
<http-loader></http-loader>
Enter fullscreen mode Exit fullscreen mode

So far, the loader appears in the right place. Moving on.

Loader state service

We are going to use the same RxJS state management class we developed earlier to create a state service for the loader. The RxJS class is basically the following:

// using our previously created state class
export class StateService<T>  {

    protected stateItem: BehaviorSubject<T | null> = new BehaviorSubject(null);
    stateItem$: Observable<T | null> = this.stateItem.asObservable();

    get currentItem(): T | null
        return this.stateItem.getValue();
    }

    // return ready observable
    SetState(item: T): Observable<T | null> {
        this.stateItem.next(item);
        return this.stateItem$;
    }

    UpdateState(item: Partial<T>): Observable<T | null> {
        // extend the item first
        // using lodash clone (found in core/common.ts)
        const newItem = { ...this.currentItem, ...clone(item) };
        this.stateItem.next(newItem);
        return this.stateItem$;
    }

    RemoveState(): void {
        this.stateItem.next(null);
    }
}
Enter fullscreen mode Exit fullscreen mode

Then we create the loader interface, and the state service inheriting from our root service:

// this is a state for the loader
// lets create the interface as well
export interface ILoaderState {
  show: boolean; // to show the loader bar
}

@Injectable({
  providedIn: 'root',
})
export class LoaderState extends StateService<ILoaderState> {}
Enter fullscreen mode Exit fullscreen mode

Now we start listening to the observable in our loader component

// change loader.partial.ts to listen to the state service
@Component({
  selector: 'http-loader',
  template: `<div class="httploader" *ngIf="signal$ | async">
  ...`,
  // ...
})
export class LoaderComponent implements OnInit {
  // inject service and listen
  signal$: Observable<boolean>;
  constructor(private loaderState: LoaderService) {}

  ngOnInit() {
    // assign state
    this.signal$ = this.loaderState.stateItem$.pipe(
      // i just need a sub property
      // filter out nulls as well
      map((state) => state ? state.show : false)
    );
    }
}
Enter fullscreen mode Exit fullscreen mode

Now back to our main consumer in application root. To test the loading effect, we can call the UpdateState directly to see it in action.

// testing the loading effect with direct calls
this.loaderState.UpdateState({
    show: true // or false
});
Enter fullscreen mode Exit fullscreen mode

Now let's make proper use of it.

Http interception

Going back to our Angular Standalone HttpClient providers, we are going to create an Http interceptor function, and provide it in the root. This is where the action will sit.

// http interceptor function with an injected service
export const AppInterceptorFn: HttpInterceptorFn = (
  req: HttpRequest<any>,
  next: HttpHandlerFn
) => {
  // lets inject our service here (inject function imported from @angular/core)
  const loaderState = inject(LoaderState);

  // first show loader
  loaderState.UpdateState({ show: true });
  return next(req).pipe(
    // when everything is done, hide
    finalize(() => {
      loaderState.UpdateState({ show: false });
    })
  );
};
Enter fullscreen mode Exit fullscreen mode

In our AppModule

// app.module
@NgModule({
  //...
  // this is how interceptor functions are provided
  providers: [provideHttpClient(withInterceptors([AppInterceptorFn]))],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Then if we create an Http call via the HttpClient as the example in StackBlitz, we should see the loader appear then disappear.

I used https://slowfil.es/ to mimic a slow loading call. You might want to change responseType to text in Angular to see results, but it does not matter much.

Multiple listeners

Say we have another panel on the page that connects to HttpClient call, but we want it to dance a bit before it finishes, along with the main loader. An example would be a button, once clicked, it would show a "loading" icon. Let's add the button, and make it click to call an Http.

<!-- Let button change class when loading of http changes -->
<button class="btn" (click)="callFn()" [class.loading]="loading$ | async">
  Call random web function
</button>
Enter fullscreen mode Exit fullscreen mode

Now all we have to do is tap into the loaderState

// constructor injects loader state
loading$: Observable<boolean>;
constructor(private loaderState: LoaderState) {}

// somewhere on init, or afterview or prop settings
// depends on where the button is located:
ngOnInit() {
  this.loading$ = this.loaderState.stateItem$.pipe(
    map(state => state ? state.show : false)
  );
}
Enter fullscreen mode Exit fullscreen mode

We will use this method to create the same effect in a pagination control.

Pagination

Another helpful scenario is when we want the loading effect to appear elsewhere in addition. Like the continuous pagination. The icon should dance a little while loading the second page. Let's first create a pagination component, that simply outputs a click event to trigger a pagination request.

// pagination partial component
@Component({
  selector: 'gr-pager',
  template: `
    <div class="pager">
      <button class="btn-fake" (click)="page($event)">More</button>
    </div>
  `,
 //...
})
export class PagerPartialComponent implements OnInit {
  @Output() onPage: EventEmitter<any> = new EventEmitter();

  page(event: any): void {
    this.onPage.emit(event);
  }
}
Enter fullscreen mode Exit fullscreen mode

Wherever we use this control, we listen to the onPage event to make an Http request for the next page. Now we update the control to listen to the loading state, to change the style of the control according to busy state:

@Component({
  selector: 'gr-pager',
  template:
    // change class by listening to an observable
        `
    <div class="pager" [class.loading]="loading$ | async">
      <button class="btn-fake" (click)="page($event)">More</button>
    </div>
  `,
  //... add style for loading effect
})
export class PagerPartialComponent implements OnInit {
  // ...
  // make pager react to global loading state
  loading$: Observable<boolean>;
  // inject state
  constructor(private loaderState: LoaderState) {}
  ngOnInit(): void {
    // extract the show state
    this.loading$ = this.loaderState.stateItem$.pipe(
      map((state) => state.show)
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Not all loaders are equal: multiple instances

It is a good practice to continue to show the loading effect of the whole page while a single loader is loading, but what if we have two loaders (two panels or two paginated lists) on the same page? How do we distinguish whether to listen and react or just sit there and wait?

The affair is a single trigger (Http), with multiple listeners. State management won't fix this. The only solution is to fine tune the input that caused the trigger. For example:

general calling...

Peter calling...

Sally calling...

The listener can then decide to filter out the noise and respond only to one caller.

HttpContext solution

In the above case the update statement occurs in an Http interceptor, not an easy place to pass arguments to. We can do that with HttpContext to send the caller source.

First, in the Http interceptor function, we need to setup the token, and set the state to hold the value of that token, like this:

// http.fn.ts interceptor
// create a context token
export const LOADING_SOURCE = new HttpContextToken<string>(() => '');

// http interceptor function with an injected service
export const AppInterceptorFn: HttpInterceptorFn = (
  req: HttpRequest<any>,
  next: HttpHandlerFn
) => {
  // pass the context to state
  loaderState.UpdateState({
    show: true,
    source: req.context.get(LOADING_SOURCE),
  });
  return next(req).pipe(
    // when everything is done, hide
    finalize(() => {
      loaderState.UpdateState({
        show: false,
        // must do, so that the state of loader does not leak to concurrent loaders
        source: req.context.get(LOADING_SOURCE),
      });
    })
  );
};
Enter fullscreen mode Exit fullscreen mode

And whenever we fire an Http call, we need to specify the value of that token, like this:

callHttp2() {
  // mimic a slow loading call
  this.http
    // a slower url
    .get('https://slowfil.es/file?type=js&delay=1000', {
      // assign token to pager2, or any specific value
      context: new HttpContext().set(LOADING_SOURCE, 'pager2'),
    })
    // ...
    .subscribe();
}
Enter fullscreen mode Exit fullscreen mode

Then of course that value need to be fed to the pager component,

// pager component
export class PagerPartialComponent implements OnInit {
  // expect specific source
  @Input() source?: string;

  ngOnInit(): void {
    this.loading$ = this.loaderState.stateItem$.pipe(
      // then filter for that source
      filter((state) => state?.source === this.source),
      map((state) => state.show)
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

And the components using it must be specific:

<gr-pager (onPage)="callHttp2()" source="pager2"></gr-pager>

This needs some tidying up, but it works. Every trigger now acts separately. General listeners can filter for empty context, and the main loading effect of the app, from a user experience point of view, should always dance.

Find enhanced version in StackBlitz, where the pager component takes care of its own source by passing it back with the emitted event, this can be enhanced further but it is out of context of this article.

Concurrent calls

Here is a common scenario: call A, then call B on the same page. Then A ends. The loading bar thinks it's over. But it's not. B has not ended yet. How do we go on fixing that particular problem? There is one way, quite technical, and another, conceptual.

Technically we can keep track of initialized calls, and when they finalize we remove them from the set. Then when the set is completely empty, we can hide the loading bar.

The easier way though is to watch for the total number of busy connections. Let's try and build it in our project. To mimic a case with multiple loading events, we'll create four panels, expected to load something at different speeds. (Again, using slowfil.es).

// example page with four segments. Each waits sometime before it loads an http request
@Component({
  templateUrl: './dashboard.html',
  //...
})
export class ProjectDashboardComponent implements OnInit {
  // mimic four different calls

  panel1$: Observable<any>;
  panel2$: Observable<any>;
  panel3$: Observable<any>;
  panel4$: Observable<any>;

  // a service that calls the http client
  constructor(private projectService: ProjectService) {}

  ngOnInit(): void {
    // passing random numbers for ms delays (see https://slowfil.es/)
    // the loading effect stops after the first panel is loaded.
    this.panel1$ = this.projectService.GetProjectPanel('2000');
    this.panel2$ = this.projectService.GetProjectPanel('2500');
    this.panel3$ = this.projectService.GetProjectPanel('3000');
    this.panel4$ = this.projectService.GetProjectPanel('4000');
  }
}
Enter fullscreen mode Exit fullscreen mode

A counter that increases and reduces until it reaches 0 is the way forward. (Let's also refactor and move the show and hide functions to the state service.)

// http interceptor function
export const AppInterceptorFn: HttpInterceptorFn = (
  //...
) => {
  //...
  // move show and hide out of here
  loaderState.show(req.context.get(LOADING_SOURCE));

  return next(req).pipe(
    finalize(() => {
      // move show and hide out of here
      loaderState.hide(req.context.get(LOADING_SOURCE));
    })
  );
};
Enter fullscreen mode Exit fullscreen mode

The loader state service:

// loader state service with new show and hide.
export interface ILoaderState {
  show: boolean;
  source?: string;
  // and currently active property
  current: number;
}

@Injectable({providedIn: 'root'})
export class LoaderState extends StateService<ILoaderState> {

  constructor() {
    super();
        // initiate state with 0
    this.SetState({ show: false, current: 0 });
  }
  show(context?: string) {
    // update current + 1
    const newCurrent = this.currentItem.current + 1;
    this.UpdateState({
      show: true,
      source: context,
      current: newCurrent,
    });
  }
  hide(context?: string) {
    // update current -1
    const newCurrent = this.currentItem.current - 1;
    this.UpdateState({
      show: false,
      source: context,
      current: newCurrent,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Now we let our loading component check for 0 currently active connections to show:

// loader.partial component
ngOnInit() {
    this.signal$ = this.loaderState.stateItem$.pipe(
      map((state) => {
        // if no state return false and hide
        if (!state) return false;
        // if showing, or the current is not zero, show
        if (state.show || state.current > 0) {
          return true;
        }
        // else wait till state current is 0 to hide
        return false;
        // snobby way:
        // return (state.show || state.current > 0);
      })
    );
Enter fullscreen mode Exit fullscreen mode

If for whatever reason the Http call did not finalize, the loader bar will not disappear. But by then, you have a lot more serious issues to fix than that, so we'll take it easy and accept that risk.

User experience

Conceptually however, from a user experience point of view, you almost never need to go that extreme. Consider this: a restaurant details page, with three Http requests: general information, reviews and menu; the panel that does not load fast enough, is already a bad experience, so you're better off with not reminding your users of your shortcomings. In a situation like that with generic information to display with no particular urgency, hiding the loading bar as soon as there is something to see is a better experience.

All sites have shortcomings, the larger the scale of the site, the more common it is to fail. If users can do nothing to fix them, don't rub it in their faces, it will probably fix itself a few reroutes down the road.

Thank you for reading this far while watching those boring loading bars. Did that make you dizzy?

RESOURCES

RELATED POSTS

Creating a loading effect using RxJs in Angular, Angular - Sekrab Garage

Angular RxJS based state management

favicon garage.sekrab.com

Top comments (0)