Home > Software design >  Angular: *ngIf failing using concatMap with async pipe
Angular: *ngIf failing using concatMap with async pipe

Time:06-11

If I have an Observable releasing values over time:

values$ = from([1, 2, 3, 'done'])
  .pipe(
    concatMap((x) => of(x).pipe(delay(1000)))
  );

And I have a function returning access to that Observable:

getOutputs(): Observable<'done' | number> {
    return this.values$;
  }

And subscribe to the Observable through a function in the template using *ngIf and async:

<div *ngIf="getOutputs() | async as val">
  <hello name="{{ val }}"></hello>
</div>

The behavior is expected: the browser shows 'Hello 1!', 'Hello 2!', 'Hello 3!', 'Hello done!', with an interval for each of about a second.

If, instead, I store the latest value in a BehaviorSubject and cycle all of the values through that BehaviorSubject in ngOnInit:

outputs$ = new BehaviorSubject<number | 'done' | null>(null);
ngOnInit(): void {
    this.subscriptions.add(
      from<[number, number, number, 'done']>([
        1,
        2,
        3,
        'done',
      ]).subscribe((val) => this.outputs$.next(val))
    );
  }

The behavior is of course different: the values are all sent to the BehaviorSubject, and outputs$.value becomes 'done' very quickly. So anything coming along later and subscribing would only get 'done'. Also expected.

If I change getOutputs() to use this.outputs$ instead, I just get 'Hello done!':

getOutputs(): Observable<null | 'done' | number> {
    return this.outputs$;
  }

But if I add the same concatMap used earlier, like this:

getOutputs(): Observable<null | 'done' | number> {
    return this.outputs$
    .pipe(
      concatMap((x) => of(x).pipe(delay(1000)))
    );
  }

'done' gets sent over and over, once a second (which can be seen through tap(console.log)), but the template shows nothing. This is unexpected: I would think that the HTML would show 'Hello done!'.

Why is this happening?

See this Stackblitz.

CodePudding user response:

TL;DR

This is caused by how Angular handles change detection.

Angular will regularly check that your view is up-to-date with the data in your model, and is in fact continuously calling your getOuputs() method, once every second!


Angular's Change Detection in a nutshell

Consider your app.component.html template:

<div *ngIf="getOutputs() | async as val">
  <hello name="{{ val }}"></hello>
</div>

Here, Angular will regularly re-evaluate getOutputs() | async, a "couple of times", until your application is in a stable state.

However, for each evaluation, you are returning a new, unique Observable, because you create that new, unique Observable in your getOutputs method:

public getOutputs(): Observable</* ... */> {
  return this.outputs$.pipe(
     concatMap(x => of(x).pipe(delay(1000))),
  ); // it's not `this.outputs$`, it's a **new Observable**
}

Therefore, if you where to create another member like that:

 export class AppComponent implements OnInit, OnDestroy {
   private subscriptions = new Subscription();
   private outputs$ = new BehaviorSubject</* ... */>(null);
   private actualOutputs$ = this.outputs$.pipe(
     concatMap(x => of(x).pipe(delay(1000))),
   );

   public getOutputs(): Observable</* ... */> {
     return this.actualOutputs$;
-    return this.outputs$.pipe(
-       concatMap(x => of(x).pipe(delay(1000))),
-    );
   }
 }

(welp, StackOverflow doesn't support diff syntax highlighting, sorry about that...)

... then your application would behave exactly as you expect!


Some more exploration

But then why does removing the delay, yet yielding a different Observable every time also works?

Let's consider the alternative implementation for getOutputs below:

public getOutputs(): Observable</* ... */> {
  return this.outputs$.pipe(
    concatMap(x => of(x)/* .pipe(delay(1000)) */), // no more delay here
  ); // that's a **new** Observable every time.
}

It's because I (purposely

  • Related