angularasync-pipe

does multiple async pipe on a same observable in template cause more change detections?


I am working on a legacy angular code, the original developer has gone. I saw a lot of async pipes in the template. does it cause more change detections?

template:

      <div>{{(cart$ | async)?.name}}</div>
      <div>{{(cart$ | async)?.price}}</div>
      <div>{{(cart$ | async)?.count}}</div>

should I refactor the code, let parent component template async pipe the cart$ and pass it to child component? I know code is much cleaner this way, other than that, is there any other advantages? thanks!

<child [cart]="cart$ | async"></child>

Solution

  • Does using the AsyncPipe multiple times on the same observable cause additional change detection cycles? No, not necessarily. When the observable emits, all the subscribers (one per use of the AsyncPipe) will mark the component for change detection, but once a component is marked, it will be checked in the next cycle. Marking it for change again in the same cycle has no effect.

    The issue you will run into with multiple subscriptions via AsyncPipe is that each subscription is a potential API call or side effect trigger.

    For simple observables, this isn't an issue. It doesn't matter that we're making multiple subscriptions to data$ in the example below.

    data$ = new BehaviorSubject<int>(0);
    
    <p>{{ data$ | async }}</p>
    <p>{{ data$ | async }}</p>
    <p>{{ data$ | async }}</p>
    

    Even a small change can cause this to become a problem. Consider the case where we have to trigger some side effect:

    counter = 0;
    data = new BehaviorSubject<int>(0);
    data$ = this.data.pipe(
      tap(() => console.log(++this.counter))
    );
    
    <p>{{ data$ | async }}</p>
    <p>{{ data$ | async }}</p>
    <p>{{ data$ | async }}</p>
    

    We'd expect that each time data emits a new value, our template updates and we see the counter incremented by one. What we actually see is the counter get incremented by one three times. Each subscription re-triggered the side effect in tap. You can imagine how much worse this would be if the side effect were expensive, if the side effect changed critical state, or if we used switchMap/concatMap/mergeMap to perform some API call from the stream.


    If you do need to create only a single subscription, you can do it one of two ways: ng-container or extracting the subscription into the parent component.

    If the child should be managing the subscription, it's okay to leave it in the child. You can use a pattern like the following to subscribe once and share the result.

    <ng-container *ngIf="data$ | async as data">
      <p>{{ data }}</p>
      <p>{{ data }}</p>
      <p>{{ data }}</p>
    </ng-container>
    

    If the child is merely presentational (it doesn't know where the data came from, doesn't modify the data, etc), then you should probably extract the subscription to the parent. You might even want to set the child's change detection strategy to OnPush to avoid triggering change detection unless the parent provides new data.

    @Component({
      selector: 'app-child',
      template: `
        <p>{{ data }}</p>
        <p>{{ data }}</p>
        <p>{{ data }}</p>
      `,
      changeDetection: ChangeDetectionStrategy.OnPush
    })
    export class ChildComponent {
      @Input() data: int;
    }
    
    @Component({
      selector: 'app-parent',
      template: `
        <app-child [data]="data$ | async"></app-child>
      `
    })
    export class ParentComponent {
      counter = 0;
      data = new BehaviorSubject<int>(0);
      data$ = this.data.pipe(
        tap(() => console.log(++this.counter))
      );
    }