TL;DR

  • Angular's built-in AsyncPipe is a straightforward utility for binding Observable values into the view.
  • While it handles many scenarios effectively, several drawbacks emerge:
    • Working with falsy values can become cumbersome.
    • It doesn't function in environments without Zone.js.
    • It only reacts to next notifications, leaving error and complete unhandled.
    • Complex cases demand verbose boilerplate, particularly when paired with *ngIf.
  • These challenges can be addressed with a structural LetDirective.
  • The LetDirective provides all essential AsyncPipe capabilities—like dynamically switching context for various Observable notifications and managing subscriptions automatically during component lifecycle—while offering a richer, more ergonomic API.
  • The LetDirective enables view switching based on the source Observable's complete and error notifications.

Introduction

Angular offers a highly convenient method for connecting an Observable to the view context. The well-known AsyncPipe fulfills this role perfectly:

<app-hero-card [hero]="hero$ | async"></app-hero-card>

The async pipe subscribes to an Observable or Promise and returns the latest value it has emitted. When a new value is emitted, the async pipe marks the component to be checked for changes. When the component gets destroyed, the async pipe unsubscribes automatically to avoid potential memory leaks.
(source: official docs)

At its core, the AsyncPipe performs its duties effectively:

  • It applies values emitted from the source Observable to the view.
  • It stops emitting when the source Observable completes.
  • It halts emission and logs an error to the console when the source encounters an error.

These capabilities cover all three Observable notifications—the distinct "phases" an Observable can enter.

However, my focus here isn't to introduce the AsyncPipe; I'll assume you're already familiar with it. Instead, I'd like to highlight some problems you might face when using it, building on what we currently know.

Challenges with the AsyncPipe

Zone.js and rendering performance

This issue is both fascinating and intricate, as it tackles rendering performance concerns for large-scale Angular applications. Solutions for building apps that avoid Zone.js entirely are gaining traction, and there are even initiatives to drop Zone.js from Angular itself. I'll delve into specifics later, but how does this relate to the AsyncPipe?

The AsyncPipe depends on Zone being active—it doesn't trigger change detection on its own. It merely flags the component and its ancestors as dirty, relying on Zone to initiate change detection. Consequently, in a zone-less setup, the AsyncPipe becomes largely ineffective.

For more insights into developing reactive, zone-less Angular applications, efficient reactive rendering, and why it shapes Angular's future, check out this excellent talk:

Displaying context-based templates

This point centers on the code complexity involved when we need to show different templates based on the Observable notification.

To illustrate, consider this code snippet:

▶️ Live example available at Stackblitz.

@Component({
  selector: 'my-app',
  template: `
    <p *ngIf="count$ | async as count; else loading">
      Count: {{ count }}
    </p>

    <ng-template #loading>
      Loading...
    </ng-template>
  `
})
export class AppComponent  {
  count$ = interval(1000).pipe(
    delay(2000),
    tap(console.log),
    switchMap(i => {
      if (i > 5) {
        return throwError(new Error('BOOM!'))
      }
      return of(i);
    })
  );
}

That's the typical approach for rendering values from a source Observable using async combined with *ngIf. We even have the option to show a "loading" template while waiting for the first emission (the delay is simulated with a delay operator). However, several issues emerge from this implementation, many of which I outlined earlier:

  • The emitted value 0 gets treated as a loading state. The *ngIf directive interferes with rendering, and when falsy values like 0 are emitted, the view fails to display.
  • Errors thrown in the Observable (simulated with the throwError operator) aren't surfaced to the user; they're merely logged.

We can hack around this by introducing a separate Observable to signal whether an error occurred:

▶️ Live example available at Stackblitz.

@Component({
  selector: 'my-app',
  template: `
    <p *ngIf="count$ | async as count; else loadingOrError">
      Count: {{ count }}
    </p>

    <ng-template #loadingOrError>
      <p *ngIf="isError$ | async; else loading">
        Error!
      </p>

      <ng-template #loading>
        Loading...
      </ng-template>
    </ng-template>
  `
})
export class AppComponent  {
  isError$ = new BehaviorSubject<boolean>(false);

  count$ = interval(1000).pipe(
    delay(2000),
    tap(console.log),
    switchMap(i => {
      if (i > 5) {
        return throwError(new Error('BOOM!'))
      }
      return of(i);
    }),
    catchError(e => {
      this.isError$.next(true);
      return of(0);
    })
  );
}

It functions, but it's far from elegant. The nested <ng-template> elements and the extra isError$ Observable to manage the count$ error and loading states add unnecessary clutter. Additionally, the trick of emitting a falsy value on error (within the catchError callback) to toggle templates is hard to stomach. We can certainly improve this!


As it turns out, we can tackle all these issues ourselves! My goal with this article is to stretch your thinking a bit—introducing you to the often-overlooked craft of creating Angular structural directives. I'll guide you through building a structural directive that addresses all the AsyncPipe shortcomings mentioned—the LetDirective.

For a deeper dive into the potential pitfalls of the AsyncPipe (especially performance-related) and their solutions, check out this article:

Requirements

Every feature needs a set of requirements, so let's compile ours for the LetDirective. Some were covered earlier, but I'll list them all here for clarity:

  • It applies values emitted from the source Observable to the view.
  • It stops emitting when the source Observable completes.
  • It stops emitting and displays an error message to the console when the source raises an error.

Additionally, we can infer one more from the AsyncPipe description:

  • Subscribe automatically and unsubscribe on the component's destruction.

So far, it's straightforward—these are just the essentials of the trusty AsyncPipe. Now for the exciting part: what if we want to bind distinct templates to different Observable notifications? With the traditional AsyncPipe, we'd rely on nested async plus *ngIf combos. That works until we run into issues like:

  1. The *ngIf directive checks for truthiness—what if we need to render a value like 0?
  2. Displaying templates for "error" or "complete" notifications is possible, but it necessitates hacks like catchError or tap to intercept those notifications. It's workable, but far from clean. Why settle when we can do better?

Here's how the LetDirective, once implemented as I envision, would be used:

<ng-container *rxLet="hero$; let hero; error: error; complete: complete">
  <app-hero-card [hero]="hero"></app-hero-card>
</ng-container>

<ng-template #error>Error while loading hero feed.</ng-template>
<ng-template #complete>Hero feed completed!</ng-template>

Pretty slick, isn't it? No custom piping or combining multiple directives needed. Let's add this to our requirements:

  • Display a different template (if provided) on different Observable notification—"next", "error", and "complete".

That should suffice; we've got plenty to tackle. Fire up your editor, open your Angular project, and let's dive in!

Implementation

Define the view context

First, let's establish our view context. You might wonder, "What's that?" It's the data linked to the view we aim to render. Here, our primary focus is on binding values from the source Observable to our view:

export interface LetViewContext<T> {
  $implicit: T;
}

$implicit is an Angular-specific field that the compiler recognizes as holding the default value from the provided context. For us, that's the value from the source Observable. Defining $implicit also allows us to use the observable$; let o syntax in our directive, e.g.:

<ng-container *rxLet="hero$; let hero">
  <app-hero-card [hero]="hero"></app-hero-card>
</ng-container>

Now, let's add more fields to our view context object for other Observable notifications:

export interface LetViewContext<T> {
  $implicit: T;
  $error: Error;
  $complete: boolean;
}

We'll later incorporate logic into our directive to adjust the view context based on the current Observable notification—for instance, rendering an Error message when one occurs. For "complete," we'll keep it simple as a boolean flag indicating if the source Observable has finished.

? Bonus round

We could add another context field to enable the observable$ as o syntax. To do so, we'd include an rxLet field:

export interface LetViewContext<T> {
  $implicit: T;
  $error: Error;
  $complete: boolean;
  rxLet: T;
}

Why rxLet? It must match our directive's selector. Then, this syntax becomes valid:

<ng-container *rxLet="hero$ as hero">
  <app-hero-card [hero]="hero"></app-hero-card>
</ng-container>

While this syntax may appear cleaner, there's a caveat. With the Angular version current when this article was written, the type of the value "unwrapped" from the Observable might not be inferred correctly. Angular's Language Service, responsible for type inference in templates, isn't fully Ivy-compatible yet and struggles with custom structural directives. Thus, for this article, I'll stick with the observable$; let o syntax.

Create the LetDirective and its first input binding

Let's generate our directive using the Angular CLI:

ng g d let

After tweaking the selector and adding two interface declarations (needed later), we end up with:

@Directive({
  selector: '[rxLet]'
})
export class LetDirective implements OnInit, OnDestroy {
}

I should note that eventually, we'll need to reference the type of the value from the source Observable bound to the LetDirective. We can't predict that type—it's determined by the user. So, let's introduce a generic type for the entire LetDirective class:

@Directive({
  selector: '[rxLet]'
})
export class LetDirective<T> implements OnInit, OnDestroy {
}

Now, building on the previous point, let's add the initial setup to the LetDirective class:

private readonly viewContext: LetViewContext<T> = {
  $implicit: undefined,
  rxLet: undefined, 
  $error: undefined,
  $complete: false,
};

With that in place, it's time to create our first input binding for the most crucial value—our source Observable:

@Input()
set rxLet(sourceObservable: Observable<T>) {
  // ...
}

Now, recall one of our requirements:

  • Subscribe automatically and unsubscribe on the component's destruction.

We must subscribe to our source Observable once we receive it from the input binding—that's the only way to access emitted values, after all.

@Input()
set rxLet(sourceObservable: Observable<T>) {
  sourceObservable.pipe(
    distinctUntilChanged()
  ).subscribe();
}

You might have noticed the distinctUntilChanged operator—it helps prevent needless re-renders when the emitted value remains unchanged. Additionally, we must unsubscribe when the view is destroyed, so we'll store the Subscription somewhere:

@Directive({
  selector: '[rxLet]'
})
export class LetDirective<T> implements OnInit, OnDestroy {

  @Input()
  set rxLet(sourceObservable: Observable<T>) {
    // unsubscribe from previous Subscription if a new source Observable is provided
    this.subscription.unsubscribe();  
    this.sourceObservable = sourceObservable.pipe(
      distinctUntilChanged()
    );
    this.subscription = new Subscription().add(this.sourceObservable.subscribe());
  }

  private subscription = new Subscription();
  private sourceObservable: Observable<T>;

  // ...

  ngOnDestroy(): void {
    this.subscription.unsubscribe();
  }
}

With that in place, we can check off one of the five requirements:

  • Subscribe automatically and unsubscribe on the component's destruction.

Recap of What We Covered

By now, we've accomplished quite a bit together, so let's pause and take a breath. Here's a rundown of what we've tackled:

  • Explored the common pitfalls developers encounter when relying on the AsyncPipe.
  • Addressed a frequent scenario where the async pipe is combined with *ngIf, and examined where there was room for improvement.
  • Drafted a set of requirements for our LetDirective, designed to overcome each of the challenges we identified.
  • Laid down a robust groundwork for the upcoming section—we constructed a strongly-typed view context, brought the LetDirective class to life, and wired up the initial input property.

What lies ahead promises even more depth. In the next installment, we'll dive straight into the core mechanism: swapping out the current view dynamically, depending on the kind of notification emitted by the source Observable. See you then, and thanks for sticking around!