DEV Community

Rens Jaspers
Rens Jaspers

Posted on • Edited on

Stop Pretending The Error Never Happened! Be Explicit About Loading State

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([]))
);
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

...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 };
Enter fullscreen mode Exit fullscreen mode

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"})
);
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

…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"})
  );
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode
<div *ngxLoadWith="emails$ as emails">
  @for (email of emails; track email.id) {
    <div>{{ email.title }}</div>
  } @empty { 
    You have no emails. 
  }
</div>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

…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)

Collapse
 
rensjaspers profile image
Rens Jaspers • Edited

Shoutout to @simonjaspers for suggesting the use of a union type for the loading state, instead of a single type with optional error and data 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 have data or error properties, and for @case("loaded"), it understands that there will be data but there will be no error value. So trying to access {{emails.error.message}} in the template under @case("loaded") will throw a compilation error. :-)

Collapse
 
jangelodev profile image
João Angelo

Hi Rens Jaspers,
Your tips are very useful
Thanks for sharing

Collapse
 
rensjaspers profile image
Rens Jaspers

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