Managing multiple loading states in Angular
Maxence Lecanu9 min read
When developing an Angular application, you will probably have to perform HTTP requests. Whether it be fetching data from a separate backend application or an external api, your application might have to wait for a response at some point.
Properly handling the state of an HTTP request in a web application is a common use case. In a large codebase, it quickly becomes a critical matter of architecture. This matter is even more important than Angular is a verbose framework.
In this context, your solution to this concern has to be :
- Simple enough to keep your codebase readable
- Reusable enough to keep your codebase sane and to save time when creating new pages or components
- Adaptive enough to handle the different use cases of your application
This article aims at presenting several ways of dealing with a request’s loading or error in Angular, with regards to these three aspects.
Why should we care about showing error messages and loaders
The first main reason for having a hard time on this, is because we care about our users. We want them to understand easily what is going on the application, and we don’t want them to click ten times on a button while waiting for an async operation to be performed.
Thus, it is important to give them as many feedback as we can when we perform async stuff.
RxJS
Angular asynchronous execution management essentially lean on reactive programming, using RxJS. Thus, every pattern presented here involve this library.
In this article, I will mention Observables
and BehaviorSubjects
, if you are not familiar with reactive programming and especially with these 2 objects, I invite you to check out the very well designed documentation of the library!
Managing one request state using the component state
Principle
The first and easiest way to keep tracks of your requests is to directly watch it within your component.
Let’s say we build an api for displaying cute little pictures of cat breeds.
Our component declares two boolean attributes for storing the current state of the request (loading and error) and these attributes are updated throughout the data fetching operation:
What it looks like in a component
@Component({
selector: 'app-fetch-breeds',
templateUrl: './fetch-breeds.component.html',
})
export class FetchBreedsComponent {
constructor(private myService: MyService) {}
loading = false;
error = false;
errorMessage = '';
fetchBreeds() {
// Reset request error state data
this.error = false;
this.errorMessage = '';
// Set the component in a loading state
this.loading = true;
// Start the fetch event pipeline involving :
this.myService.fetchBreeds().pipe(
// A handler which is called when our service triggers an error and
// which is dedicated to setting the error in a corresponding state
catchError(err => {
this.error = true;
this.errorMessage = err.message; // Or whatever error message you like
})
// A callback which is always called whether an error has been triggered
// or not.
// It is responsible for setting the component in a non-loading state.
finalize(() => {
this.loading = false;
})
)
.subscribe();
}
}
<div>
<!-- If we are fetching the data, we display a loader -->
<app-loader *ngIf="loading"></app-loader>
<button (click)="fetchBreeds()">See these magnificent cats!</button>
<!-- If there is an error (and we are done waiting for a response), we display it -->
<app-error-message *ngIf="!loading && error">
{{ errorMessage }}
</app-error-message>
</div>
Managing request state within a service
Why delegating this logic to a service ?
Using services in an Angular application offers many advantages (DRY principle, efficient testing among others). In our case, it also allows us to persist and share data between components / other services.
It also allows abstraction: your component is dedicated to the UI display while a service handles any inner complex logic of the component.
Case 1 - The state is shared between multiple components
Let’s say we have two components offering two features based on the cats entity: on one page we see the pictures of the breeds and on the other page we can see some technical details about these breeds.
This architecture involves the duplication of the logic responsible for sending the request, catching errors, and handle a loading state in between.
This logic could be moved into a dedicated service, which will expose one observable for the loading information and one for the errors:
@Injectable()
export class CatBreedsService {
constructor(private http: HttpClient) {}
private _loadingSubject = new BehaviorSubject<boolean>(false)
isLoading$: this._loadingSubject.asObservable();
// We expose observables because we want our components to have a read access
// but no write access to the information
private _errorSubject = new BehaviorSubject<string | null>(null);
erros$: this._errorSubject.asObservable();
fetchBreeds(): Observable<CatBreed[]> {
this._loadingSubject.next(true);
this._errorSubject.next(null);
return this.http.get('/cat-breed').pipe(
catchError(err => {
this._errorSubject.next(err);
return of([]);
}),
finalize(() => this._loadingSubject.next(false))
)
}
}
Now, the components only have to call the fetch helper from the service, and the loading / error state information about the entity will be shared across the entire application.
@Component({
selector: "app-list-cat-breeds",
templateUrl: "./list-cat-breeds.component.html",
})
export class ListCatBreedsComponent implements OnInit {
constructor(private catBreedsService: CatBreedsService) {}
isLoading$ = this.catBreedsService.loading$;
hasErrors$ = this.catBreedsService.errors$;
ngOnInit() {
this.catBreedsService.fetchBreeds.subscribe();
}
}
Managing multiple request states within a service
Case 2 - Concurrency Issues
Example use case: multiple occurrences of an autocomplete input
You might also encounter the case where you have to perform a request for the same operation but in multiple contexts :
For instance, let’s imagine we have a form where each input dynamically fetch autosuggestion data on change, and show a loader while the update is performed. If we create unique subjects for handling the state of this request, there will be concurrency issues.
A concrete exemple of this in our context would be a page where we can attribute breeds to cat pictures.
The problem with side effects
In this case, since there is only one loading subject for every input, a change on any input will put all the other ones in their loading state or error state.
The same happens with the suggestions observable. It triggers unnecessary renderings in your application, which may have catastrophic consequences for the performance of the application.
Side effects mean you have to break down this component into multiple parts
One could handle this issue by having a complex state including a loading observable and an error observable. The code beneath shows such an implementation :
export interface InputState {
loading$: BehaviorSubject<boolean>;
error$: BehaviorSubject<string >;
}
@Component({
selector: 'app-cat-breed-form',
templateUrl: './cat-breed-form.component.html',
})
export class CatBreedFormComponent implements OnInit {
inputs = ['input1', 'input2', 'input3'];
inputsState: { [key: string]: InputState } = {};
ngOnInit() {
this.inputs.forEach(input => {
this.inputsState[input] = {
loading$: new BehaviorSubject(false),
error$: new BehaviorSubject<string | null>(null)
};
}, {});
}
setLoading(inputId: string, value: boolean) {
this.inputsState[inputId].loading$.next(value)
}
setError(inputId: string, value: string | null) {
this.inputsState[inputId].error$.next(value)
}
getLoading(inputId: string) {
return this.inputsState[inputId].loading$.asObservable()
}
getError(inputId: string) {
return this.inputsState[inputId].error$.asObservable()
}
updateField(inputId: string) {
this.setLoading(inputId, true)
this.project$.pipe(
take(1),
catchError(err => {
this.setError(inputId, err)
}),
finalize(() => this.setLoading(inputId, err))
).subscribe(() => { // do your stuff })}
}
}
A few remarks :
- The component logic is getting intricate.
- You can’t deport the fetch logic into a service, or at least you have to manage the loading/error state from within the component.
- In order to have a readable template, one will probably have to implement
getters/setters
thus increasing the code written in the component ts file.
Breaking this down into simpler components, which in this case means implementing a field component, we are back to the first case with no concurrency.
We can create a dedicated service which exposes a registration function which :
- takes an id in input
- creates a formControl on which the ‘change’ event triggers a search
- returns the formControl and the associated loading, error, and autocomplete suggestions observables.
@Injectable()
export class SearchService implements OnDestroy {
constructor(private http: HttpClient) {}
subscriptions: Subscription[] = [];
registerControl<T>(id: string, initialvalue: T) {
const control = new FormControl(initialvalue);
const _loadingSubject = new BehaviorSubject<boolean>(false);
const _errorsSubject = new BehaviorSubject<string | null>(null);
const _suggestedSubject = new BehaviorSubject<T[]>([]);
this.subscriptions.push(
control.valueChanges
.pipe(
switchMap((query: string | null) => {
if (query !== null && query.length > 0) {
return this.searchOnQuery<T[]>(query).pipe(
catchError(err => {
_errorsSubject.next(err);
return of([]);
}),
finalize(() => _loadingSubject.next(false))
);
}
return of([]);
}),
tap(suggestions => _suggestedSubject.next(suggestions))
)
.subscribe()
);
return [
control,
_loadingSubject.asObservable(),
_errorsSubject.asObservable(),
_suggestedSubject.asObservable()
];
}
private searchOnQuery<T>(query: string) {
return this.http.get<T>('/search', {
params: {
query
}
});
}
ngOnDestroy() {
this.subscriptions.forEach(sub => sub.unsubscribe());
}
}
Now we just have to register any new input within this service :
@Component({
selector: 'app-cat-breed-autocomplete-input',
templateUrl: './cat-breed-autocomplete.component.html',
styleUrls: ['./cat-breed-autocomplete.component.scss']
})
export class CatBreedAutocompleteInput implements OnInit {
@Input() initialValue: Entity;
@Input() autocompleteId: string;
control: FormControl = new FormControl();
loading$: Observable<boolean> = of(false);
error$: Observable<boolean> = of(null);
suggestions$: Observable<boolean> = of([]);
constructor(private searchService: SearchService) {}
ngOnInit() {
const [control, loading$, error$, suggestions$] = this.searchService.register<CatBreed>(
this.autocompleteId,
this.initialValue
)
this.control = control;
this.loading$ = loading$;
this.error$: error$;
this.suggestions$ = suggestions$;
}
}
These 4 observables are now available in the template and can be used with async pipes.
This allows every input to behave independently, since they have their own event management system through the FormControl API
Conclusion
In this article we have seen two different ways of handling the loading state of a request using RxJS, and how to incorporate this within your Angular architecture.
With only one loader to handle in one component, it is okay to implement the UI logic in the component. With multiples loaders at the same time, things become easily messier and we saw how to handle this new complexity in the case of multiple autocomplete inputs.