DEV Community

Suguru Inatomi
Suguru Inatomi

Posted on • Originally published at blog.lacolaco.net

Initial Null Problem of AsyncPipe and async data-binding

Original Post: https://blog.lacolaco.net/2020/02/async-pipe-initial-null-problem-en/

Angular's AsyncPipe is a useful feature for template binding of asynchronous data, but it has a big problem since the beginning. That is the "Initial Null Problem".
This article describes the Initial Null Problem of AsyncPipe and its root cause, and discusses new asynchronous data-binding to solve that.

I recommend you to see also this great article:

How AsyncPipe works

AsyncPipe is now always used to create general Angular applications. It is often used to subscribe to Observable data and bind its snapshot to a template.
The basic usage is as follows.

@Component({
  selector: "app-root",
  template: `
    <div *ngIf="source$ | async as state">
      {{ state.count }}
    </div>
  `,
  styleUrls: ["./app.component.css"]
})
export class AppComponent {
  source$ = interval(1000).pipe(map(i => ({ count: i })));
}
Enter fullscreen mode Exit fullscreen mode

So, how does AsyncPipe bind the value that source$ streams to a template and render it? Take a look at Implementation of AsyncPipe.

AsyncPipe has a lot of asynchronous data abstraction code that can handle both Promise and Observable, but the essential code is the following code. Like any other Pipe, it implements the transform() method.

  transform(obj: Observable<any>|Promise<any>|null|undefined): any {
    if (!this._obj) {
      if (obj) {
        this._subscribe(obj);
      }
      this._latestReturnedValue = this._latestValue;
      return this._latestValue;
    }

    if (obj !== this._obj) {
      this._dispose();
      return this.transform(obj as any);
    }

    if (ɵlooseIdentical(this._latestValue, this._latestReturnedValue)) {
      return this._latestReturnedValue;
    }

    this._latestReturnedValue = this._latestValue;
    return WrappedValue.wrap(this._latestValue);
  }
Enter fullscreen mode Exit fullscreen mode

Let's look at the code from the top. The first if (!this._obj) is the condition when Observable is passed to AsyncPipe for the first time, that is, the initialization process. If this._obj doesn't exist and obj does, the pipe subscribes obj. obj corresponds to source$ in the example. The Observable passed to AsyncPipe is executed subscribe() here.

The next if statement is for when an Observable has changed from the one you are subscribing. It disposes the current subscription and starts resubscribing.

And the rest of the code is for returning the latest value this._latestValue from the subscribed Observable. The returned value will be the value actually used to render the template.

What you can see here is that AsyncPipe returns the cached this._latestValue when thetransform()method is called.
This can also be seen in AsyncPipe's _subscribe() and this._updateLatestValue() methods. When the value flows into the asynchronous data subscribed by the _subscribe() method, markForCheck() of ChangeDetectorRef is called in the callback. It causes the next transform() call.

  private _subscribe(obj: Observable<any>|Promise<any>|EventEmitter<any>): void {
    this._obj = obj;
    this._strategy = this._selectStrategy(obj);
    this._subscription = this._strategy.createSubscription(
        obj, (value: Object) => this._updateLatestValue(obj, value));
  }
  ...
  private _updateLatestValue(async: any, value: Object): void {
    if (async === this._obj) {
      this._latestValue = value;
      this._ref.markForCheck();
    }
  }
Enter fullscreen mode Exit fullscreen mode

In other words, AsyncPipe renders templates using the following mechanism.

  1. Pipe's transform() is called in Change Detection
  2. Start subscribing to the passed Observable
  3. Return this._latestValue at the time transform()is called
  4. When Observable flows new data, update this._latestValue and trigger Change Detection (back to 1)

transform() must return a synchronous value, because the template can only render synchronous values. It can only return a cached snapshot at the time transform() is called.

A solid understanding of this should raise a question. That is, "at the start of the subscription, can't the transform() return a value?" And that is the biggest problem that AsyncPipe has, the "Initial Null Problem".

Initial Null Problem

Since this._latestValue is set by Observable's subscription callback, the value has never been set at the time of transform() call. However, transform() must return some value, so it returns a default value.
Let's look again at the beginning of AsyncPipe's transform().

    if (!this._obj) {
      if (obj) {
        this._subscribe(obj);
      }
      this._latestReturnedValue = this._latestValue;
      return this._latestValue;
    }
Enter fullscreen mode Exit fullscreen mode

this._latestValue used in the last two lines has never been set, so the initial value of this field will be used. Its value is null.

export class AsyncPipe implements OnDestroy, PipeTransform {
  private _latestValue: any = null;
  private _latestReturnedValue: any = null;
Enter fullscreen mode Exit fullscreen mode

In other words, AsyncPipe always returns null once before flowing the first value. Even if the original Observable is Observable<State>, it becomes State | null through AsyncPipe. This is a problem I call " Initial Null Problem".

While this problem seems serious, it has been automatically avoided in many cases. This is because *ngIf and *ngFor, which are often used with AsyncPipe, ignore the null returned from AsyncPipe.

In the following template, the value returned by source$ | async is evaluated by the NgIf directive, and if it is Truthy, it will be rendered, so if it is null, it will not go inside *ngIf.

<div *ngIf="source$ | async as state">
  {{ state.count }}
</div>
Enter fullscreen mode Exit fullscreen mode

Similarly, in the following template, the value returned by source$ | async is evaluated by the NgFor directive and ignored if it is Falsey, so if it is null, it will not be inside *ngFor.

<div *ngFor="let item of source$ | async">
  {{ item }}
</div>
Enter fullscreen mode Exit fullscreen mode

Through null-safe directives such as *ngIf and *ngFor, the Initial Null Problem does not affect the application. The problem is otherwise, that is, passing values directly to the child component's Input via AsyncPipe.
In the following cases, the child component should define a prop Input type, but you have to consider the possibility of passing null to it. If prop is a getter or setter, you can easily imagine a runtime error when trying to access the value.

<child [prop]="source$ | async"></child>
Enter fullscreen mode Exit fullscreen mode

So far, one simple best practice can be said.
AsyncPipe should always be used through a null-safe guard like NgIf or NgFor.

Replace AsyncPipe

From here, I will explore the new asynchronous data-binding that can replace AsyncPipe which has the above-mentioned problem.

Why AsyncPipe returns null is Pipe needs to return a synchronous value. The only way to solve the Initial Null Problem is to stop using Pipe for async data.

So I tried using a directive. I think an approach that accepts an input and a template and renders the template under the control of the directive, is the best replacement for AsyncPipe.

So I implemented the *rxSubscribe directive. The sample that actually works is here. It subscribe an Observable with a structural directive as follows:

<div *rxSubscribe="source$; let state">
  {{ state.count }}
</div>
Enter fullscreen mode Exit fullscreen mode

The directive is implemented as follows. What this directive does is

  1. Subscribe an Observable received by rxSubscribe Input.
  2. When the Observable value flows, embed (render) the template for the first time
  3. When the value after the second time flows, update the context and call markForCheck()

https://github.com/lacolaco/ngivy-rx-subscribe-directive/blob/master/src/app/rx-subscribe.directive.ts

@Directive({
  selector: "[rxSubscribe]"
})
export class RxSubscribeDirective<T> implements OnInit, OnDestroy {
  constructor(
    private vcRef: ViewContainerRef,
    private templateRef: TemplateRef<RxSubscribeFromContext<T>>
  ) {}
  @Input("rxSubscribe")
  source$: Observable<T>;

  ngOnInit() {
    let viewRef: EmbeddedViewRef<RxSubscribeFromContext<T>>;
    this.source$.pipe(takeUntil(this.onDestroy$)).subscribe(source => {
      if (!viewRef) {
        viewRef = this.vcRef.createEmbeddedView(this.templateRef, {
          $implicit: source
        });
      } else {
        viewRef.context.$implicit = source;
        viewRef.markForCheck();
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

With this approach, the template is not rendered until the value first flows, and re-rendering can be triggered only when the value flows. It solves the Initial Null Problem, and is also CPU-friendly because re-rendering is limited only when necessary.

By the way, the type of state in let stateis inferred from the type of source$ exactly if Ivy of Angular v9 or later, and if strictTemplates flag is enabled. When you make a mistake use of state, AOT compiler throws an error.

<div *rxSubscribe="source$; let state">
  {{ state.foo }}  <!-- compile error: state doesn't have `foo` -->
</div>
Enter fullscreen mode Exit fullscreen mode

AsyncPipe could always only infer or null due to the Initial Null Problem, but the structure directive approach can infer the context type exactly from Observable<T>.

I've published this *rxSubscribe directive as the npm package @soundng/rx-subscribe.

Conclusion

  • AsyncPipe has Initial Null Problem
  • Guarding with NgIf or NgFor can avoid the initial null
  • Pipe has limitations in handling asynchronous data
  • Structural directive approach can solve AsyncPipe problem
  • Feedback welcome to @soundng/rx-subscribe

Top comments (7)

Collapse
 
sebbdk profile image
Sebastian Vargr

Null is bad.

The checks etc. that is required for asyncPipe makes it unreliable, since the expected type changes.

Yes we can code around it with conditionals, but that's more akin to mitigation. :/

Unfortunately my impression is that the only real fix' here is to not use async pipe.

Return types should be reliable.

Collapse
 
layzee profile image
Lars Gyrup Brink Nielsen

Great explanation, Suguru! Would you like to publish it on Angular inDepth?

One observation: To use RxSubscribeDirective with NgForOf, we would have to do a nested structual directive.

Collapse
 
lacolaco profile image
Suguru Inatomi

Thank you Lars! Yes, I'd love to!

As you says, *rxSubscrive and *ngFor or other structural directives cannot be at the same place. I recommend adding <ng-container *rxSubscribe> at the root for subscribing to a stream.

<ng-container *rxSubscribe="state$; let state">
   ...sync-full template...
</ng-container>
Collapse
 
layzee profile image
Lars Gyrup Brink Nielsen • Edited

Come to our Slack workspace, and Max will set you up on the new site 🙌

I think it would be useful to show an example with a for loop such as:

<ng-container *rxSubscribe="order$; let order">
  <div *ngFor="let item of order">
    {{ item }}
  </div>
</ng-container>

Did you consider supporting microsyntax? Something like

<ng-container *rxSubscribe="state$ next state">
   ...sync-full template...
</ng-container>

Maybe even

<ng-container *rxSubscribe="state$ next state; error as err; complete: onComplete">
   ...sync-full template...
</ng-container>
Collapse
 
briancodes profile image
Brian

This is certainly an Angular In Depth standard of article👍

Collapse
 
n_mehlhorn profile image
Nils Mehlhorn

Nice article, coincidentally I recently wrote up something very similar: dev.to/angular/handling-observable...

Collapse
 
lacolaco profile image
Suguru Inatomi

Wow, really coincidentally! I'll add a link to recommend your article!