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.
Top comments (3)
Shoutout to @simonjaspers for suggesting the use of a union type for the loading state, instead of a single type with optional
error
anddata
properties.This trick prevents you from creating nonsensical loading states. For example, the TypeScript compiler will helpfully throw an error if you try to create a state like
{ state: "loading", error: error }
, as you can’t be in a loading and hold an error value at the same time.The union type also makes writing templates easier, as TypeScript can figure out which variant of the union type is being referenced with each
@switch(emails.state)
case.For
@case("loading")
, intellisense will tell you that the object won't havedata
orerror
properties, and for@case("loaded")
, it understands that there will bedata
but there will be noerror
value. So trying to access{{emails.error.message}}
in the template under@case("loaded")
will throw a compilation error. :-)Hi Rens Jaspers,
Your tips are very useful
Thanks for sharing
Hi João Angelo,
Thank you for your kind words! I'm glad you find my tips helpful. I'm curious about the projects you're working on. Maybe you'd like to try my
*ngxLoadWith
directive in your projects. I believe it can make your code simpler and improve the experience for your users when loading data. Let me know what you think!Best,
Rens