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
})
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>
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);
}
}
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> {}
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)
);
}
}
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
});
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 });
})
);
};
In our AppModule
// app.module
@NgModule({
//...
// this is how interceptor functions are provided
providers: [provideHttpClient(withInterceptors([AppInterceptorFn]))],
})
export class AppModule {}
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>
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)
);
}
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);
}
}
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)
);
}
}
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),
});
})
);
};
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();
}
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)
);
}
}
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');
}
}
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));
})
);
};
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,
});
}
}
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);
})
);
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?
Top comments (0)