Mattijs
Mattijs

Reputation: 3395

Change detection in store.select subscribe requires markForCheck. Why?

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?

Upvotes: 6

Views: 5055

Answers (1)

Explosion Pills
Explosion Pills

Reputation: 191729

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.

Upvotes: 5

Related Questions