5

My app component is having a subscribe on a store select. I set ChangeDetectionStrategy to OnPush. I have been reading about how this works; object reference needs to be updated to trigger a change. When you use async pipe however, Angular expects new observable changes and do MarkForCheck for you. So, why does my code not render the the channels (unless I call MarkForCheck) when the subscribe is triggered and I set the channels$ a new observable array of channels.

@Component({
  selector: 'podcast-search',
  changeDetection: ChangeDetectionStrategy.OnPush, // turn this off if you want everything handled by NGRX. No watches. NgModel wont work
  template: `
    <h1>Podcasts Search</h1>
    <div>
      <input name="searchValue" type="text" [(ngModel)]="searchValue" ><button type="submit" (click)="doSearch()">Search</button>
    </div>
    <hr>
    <div *ngIf="showChannels">

      <h2>Found the following channels</h2>
      <div *ngFor="let channel of channels$ | async" (click)="loadChannel( channel )">{{channel.trackName}}</div>
    </div>
  `,
})

export class PodcastSearchComponent implements OnInit {
  channels$: Observable<Channel[]>;
  searchValue: string;
  showChannels = false;
  test: Channel;

  constructor(
    @Inject( Store)  private store: Store<fromStore.PodcastsState>,
    @Inject( ChangeDetectorRef ) private ref: ChangeDetectorRef,
  ) {}

  ngOnInit() {

    this.store.select( fromStore.getAllChannels ).subscribe( channels =>{
      if ( channels.length ) {
        console.log('channels', !!channels.length, channels);
        this.channels$ =  of ( channels );
        this.showChannels = !!channels.length;
        this.ref.markForCheck();
      }
    } );
  }

I tried multiple solutions, including using a subject and calling next, but that doesn't work unless I call MarkForCheck.

Can anyone tell me how I can avoid calling markForCheck?

Mattijs
  • 3,265
  • 3
  • 38
  • 35

1 Answers1

4

This may be a bit difficult to explain, but I'll give it my best attempt. When your original Observable (the store) emits it is not bound to the template. Since you're using OnPush change detection, when this observable emits it doesn't mark the component for changes because of the lack of binding.

You are attempting to trigger a mark for changes by overwriting a component property. Even though you are creating a new reference on the component property itself, this doesn't mark the component for changes because the component is changing its own property rather than a new value being pushed onto the component.

You are correct in thinking that the async pipe marks the component for changes when a new value emits. You can see this in the Angular source here: https://github.com/angular/angular/blob/6.0.9/packages/common/src/pipes/async_pipe.ts#L139

However you will note that this only works if the value (called async), the property that you are using with the async pipe, matches this._obj, the Object that the async pipe has already recorded as being the Observable that is emitting.

Since you are doing channels$ = <new Observable>, async === this._obj is actually untrue since you are changing the object references. This is why your component is not marked for changes.

You can also see this in action in a Stackblitz I've put together. The first component overwrites the Observable passed to async pipe whereas the second does not overwrite it and updates the data by responding to changes that are emitted -- this is what you want to do:

https://stackblitz.com/edit/angular-pgk4pw (I use timer because it's an easy way to simulate a third-party unbound Observable source. Using an output binding, e.g. updating on click, is more difficult to set up since if it's done in the same component the output action will trigger a mark for changes).

All is not lost for you -- I would suggest that you do this.channels$ = this.store.select(...) instead. The async pipe handles .subscribe for you. If you're using the async pipe you shouldn't be using .subscribe anyway.

this.channels$ = this.store.select(fromStore.getAllChannels).pipe(
  filter(channels => channels.length),
  // channels.length should always be truthy at this point
  tap(channels => console.log('channels', !!channels.length, channels),
);

Note that you can use ngIf with the async pipe as well which should obviate your need for showChannels.

Explosion Pills
  • 188,624
  • 52
  • 326
  • 405
  • Thank you for your extensive answer. I think your analyses is correct, since I noticed later that if I press the search button twice, that the second time the search result would display. This proofs what you say about resetting a new reference from the async var to the new Observable assignment. I tried you suggestion, however I am running in some other strange behaviour. To start, as long as my as template `*ngFor` is not display because of `showChannels` being false. Nothing is logging in my console unless I preset `showChannels` to true and hit the search. To be continued in comment 2 – Mattijs Jul 23 '18 at 23:54
  • When I replace the `showChannels` in the template with `(channels$ | async) ?.length !== 0` it does work, but the console log fires twice. Probably because the ngIf is triggering the obersvable pipe and the actual ngFor. Your `filter` approach didnt work for me due to typing issue, but when I replace it with `map( channels => channels ),`, i can do a `channels.length` in the tap. However, this setup only works if I do not use an `*ngIf* around the for loop like described above. I find this all very interesting. (off topic: Kind of hard to respond to a question in a comment...) – Mattijs Jul 23 '18 at 23:58
  • Consider asking another question – Explosion Pills Jul 24 '18 at 05:46
  • I got a typing issue with your proposed `filter(channels => channels.length)` because it requires a double bang (!!) to become a boolean. I ask another question for the other issue – Mattijs Jul 26 '18 at 10:26
  • https://stackoverflow.com/questions/51536817/store-select-not-running-because-of-ngif-statement – Mattijs Jul 26 '18 at 10:37