We all need to stop using placeholder values that look like correctly resolved values in RxJS streams that error out!
This is a pattern you see in many tutorials:
items$ = this.http.get("api/items").pipe(
catchError(() => of([]))
);
It loads data asynchronously with RxJS, making an HTTP call to fetch a list of items from an API, and then uses the catchError
operator to catch errors. But then instead of passing on an error value, it just returns a value as if it came from the API: of([])
.
This approach is very dangerous and confusing for your users. For example, in your template, if you have:
@for(item of items$ | async; track item.id) {
<div>{{ item.title }}</div>
} @empty {
You have no items
}
...it’ll show you have no items even if there was an error. It’s misleading! How is the user going to know what is going on? Do they really have zero items or is something wrong?
I get it: those tutorials probably want to skip proper error handling to keep videos or blog posts short, but it's better to omit catchError
entirely than to teach incorrect practices. Errors should be displayed as errors!
So, how should it be done correctly?
Easy: just be explicit about returning error values. Be explicit about loading state. This can be as simple as mapping your resolved value to a loading state object such as (items) => ({ data: items, state: "loaded" })
and your errors to (error) => ({ error, state: "error" })
.
The full type could be something like:
type LoadingState<T = unknown> =
| { state: 'loading' }
| { state: 'loaded'; data: T }
| { state: 'error'; error: Error };
In your template, you can then clearly show an error if there is one, avoiding misleading the user.
For example:
emails$ = this.emailService.getEmails().pipe(
map(emails => ({ state: "loaded", data: emails })),
catchError(error => of({ state: "error", error })),
startWith({state: "loading"})
);
Just like you should be explicit about error states, you should be explicit about load completion. I recommend always including a state
property that explicitly indicates that the loading process is complete. This is another area poorly explained in tutorials: a loaded state is often implicitly equated with truthy values, which is not always accurate.
Avoid having to assume the loading state based on the raw value of your stream. Instead of guessing:
@if(emailCount$ | async; as count) {
{{ count }} emails
} @else {
Loading…
}
…which wrongly shows "Loading…" when the resolved email count equals a falsy 0, just be clear about the state by using explicit indicators.
Here’s a full example of how you could track loading and error states so you can accurately show your user what’s going on:
@Component({
selector: 'app-root',
template: `
@if (emails$ | async; as emails) {
@switch (emails.state) {
@case ("loading") { Loading... }
@case ("error") { Error: {{emails.error.message}} }
@case ("loaded") {
@for (let email of emails.data; track email.id); {
<div>{{email.title}}</div>
}
@empty { You have no emails. }
}
}
}
`
})
export class AppComponent {
private emailService = inject(EmailService);
emails$ = this.emailService.getEmails().pipe(
map(emails => ({data: emails, state: "loaded"})),
catchError(error => of({error, state: "error"})),
startWith({state: "loading"})
);
}
As you can see, being explicit about loading state and handling all the different outcomes does lead to a lot of boilerplate, and you should probably create some kind of reusable code for it.
This is exactly what I set out to do when I created *ngxLoadWith
, a structural directive that lets you do all of the above in a declarative way.
Loading data will be as simple as this:
@Component({
selector: 'app-root',
…
})
export class AppComponent {
private emailService = inject(EmailService);
emails$ = this.emailService.getEmails();
}
<div *ngxLoadWith="emails$ as emails">
@for (email of emails; track email.id) {
<div>{{ email.title }}</div>
} @empty {
You have no emails.
}
</div>
If you want to show error and loading screens, just add references to ng-template
:
<div *ngxLoadWith="emails$ as emails; loadingTemplate: loading; errorTemplate: error" ">
@for (email of emails; track email.id) {
<div>{{ email.title }}</div>
} @empty {
You have no emails.
}
</div>
<ng-template #loading>Loading...</ng-template>
<ng-template #error let-error>{{ error.message }}</ng-template>
…at the small cost of adding a tiny 1.92 kb dependency to your Angular project.
Please check it out at https://github.com/rensjaspers/ngx-load-with and let me know what you think. If you find it useful, a star 🌟 is highly appreciated.