Angular - When receiving @Input, how to wait for other async data in the children component before executing the @Input logic

Viewed 1399

PROBLEM INTRODUCTION

I have button-address child component which onInit loads a list of mapItems:

    ngOnInit() {
        this.refreshDataList();
    }

    protected refreshDataList(): void {
        this.subscription = this.getDataList()
            .pipe(
                switchMap((result: AddressModel[]) => {
                    this.dataSource.data = [...result].map((d) => {
                        return {
                            address: d,
                            selected: this.selectedItems
                                ? this.selectedItems.some(
                                      (it) =>
                                          it.address.pointName ===
                                              d.pointName && it.selected
                                  )
                                : false,
                        };
                    });
                    this.sortList(this.dataSource.data);

                    return this.preSelectUserPreferencesPOIs();
                })
            )
            .subscribe();
    }

    protected getDataList(): Observable<AddressModel[]> {
        return this.store.pipe(select(selectZoi));
    }

The value of this.dataSource.data is set inside the switchMap() operator. This value is important because later the user will select elements from the list. So I will listen for the click events from the user, find the right element of this.dataSource.data and update the selected item.

My issue is that when the app inits, I also receive an @Input from another stream whose aim is to programatically select the appropiate item in the list of this.dataSource.data:

    @Input()
    set proximityReportPoiZoi(poiZoi: ProximityReportPoiZoi) {
        this.toggleSelectedPoiZoi(poiZoi.id);
    }

    protected toggleSelectedPoiZoi(poiZoiId: string) {
        const addressZoneSelectionModel = this.dataSource.data.find(
            (item) => poiZoiId === item.address.id
        );
        const address = addressZoneSelectionModel.address;
        if (addressZoneSelectionModel.selected) {
            addressZoneSelectionModel.selected = false;
            this.showHidePoiZoi(address, false);
        } else {
            addressZoneSelectionModel.selected = true;
            this.showHidePoiZoi(address, true);
        }
    }

THE BUGGY LINE

However, because the request to the store takes some time, when the @Input (which by the way is also an observable) is received by the component, the code inside toggleSelectedPoiZoi() cannot find the appropiate item as this.dataSource.data is still empty:

        const addressZoneSelectionModel = this.dataSource.data.find(
            (item) => poiZoiId === item.address.id
        );

THE QUESTION

How can I make my @Input() wait for the component to load the data for this.dataSource.data before executing this.toggleSelectedPoiZoi()? This issue only happens during the app init.

THINGS I HAVE TRIED

  1. Await for the observable to load the data
    @Input()
    set proximityReportPoiZoi(poiZoi: ProximityReportPoiZoi) {
            const updatePoiZoiSeletion = async () => {
                await this.refreshDataList().toPromise();
                this.toggleSelectedPoiZoi(poiZoi.id);
            }
            updatePoiZoiSeletion();
    }

But it never gets to execute the following line with this.toggleSelectedPoiZoi() method. If I change .toPromise() for .subscribe(), the value of this.dataSource.data is still an empty array so no item can be selected.

  1. ngOnChanges

However no success as although I can listen for the changes on the value of the @Input, I cannot listen for changes in this.dataSource.data.

  1. Get the value of the @Input() by subscribing it at a later stage in the parent component, in ngAfterViewInit() cycle.

Angular complains that because the @Input is passed through the template, the value of proximityReportPoiZoi$ property has changed after parent component initialization. See the parent template:

        <app-button-address 
            [proximityReportPoiZoi]="(proximityReportPoiZoi$ | async)"
        ></app-button-address> 

Any help is highly appreciated beforehand :)

2 Answers

Accd. to Angular life-cycle hooks event sequence, ngOnChanges would be triggered before ngOnInit. But calling the entire subscription in the ngOnChanges might lead to performance issues since from docs:

Note that this happens very frequently, so any operation you perform here impacts performance significantly.

So what you could do is use the @Input variable inside the ngOnInit directly in the subscription.

export someComponent implements OnInit {
  _poiZoi: ProximityReportPoiZoi;

  @Input()
  set proximityReportPoiZoi(poiZoi: ProximityReportPoiZoi) {
    this._poiZoi = poiZoi;
  }

  ngOnInit() {
    this.refreshDataList();
  }

  protected refreshDataList(): void {
    this.subscription = this.getDataList().pipe(
      switchMap((result: AddressModel[]) => {
        this.dataSource.data = [...result].map((d) => {
          return {
            address: d,
            selected: this.selectedItems 
              ? this.selectedItems.some((it) =>
                  it.address.pointName === d.pointName && it.selected
                ) 
              : false,
          };
        });
        this.sortList(this.dataSource.data);
        this.preSelectUserPreferencesPOIs();
      }),
      map(() => this._poiZoi.id)
    )
    .subscribe({
      next: (poiZoiId: any) => this.toggleSelectedPoiZoi(poiZoi.id),
      error: (error: any) => console.log(error)
    });
  }
}

I propose an approach based on pure RxJs logic which does not relay on the Angular lifecycle methods.

If I understand right, you have 2 streams at play

  • the stream whose source is this.getDataList() and whose notification is used to set this.dataSource.data - let's call this stream obs_1
  • the stream that emits the value which is passed to the proximityReportPoiZoi Input and which is used to select the the initial value of the list of values - let's call this stream obs_2

The problem here is to make sure that we do the following steps in strict temporal order

  • receive the notification from obs_2
  • subscribe to obs_1 and process its notification
  • process the notification of obs_2 after the notification of obs_1 has been processed.

In terms of pure rxJs logic, this is a good case for using concatMap.

The logic could look like this

obs_2.pipe(
  concatMap(res_2 => obs_1.pipe(
      map(res_1 => ([res_1, res_2))
    )
  )
).subscribe(
  ([addressModelArray, poiZoi]) => {
     setDataSourceData(addressModelArray) // i.e. the logic in switchMap
     toggleSelectedPoiZoi(poiZoi.id);
  }
)

You can try to implement this approach within your button-address Component. This could be accomplished implementing obs_2 as a Subject which emits within the proximityReportPoiZoi set method.

The approach I would use though is to try to isolate all this logic into a service which is injected into the button-address Component. This service knows both obs_1 and obs_2 and therefore can implement this logic in the pipe and expose it as a public API Observable, something like this

public listAndSelectedVal$ = obs_2.pipe(
  concatMap(res_2 => obs_1.pipe(
      // apply some logic to filter the default value
      filter(res_1 => res_1.length > 0),
      map(res_1 => ([res_1, res_2))
    )
  )
)

button-address Component would need to subscribe tp listAndSelectedVal$ to perform its job.

It is possible also to explore the use of combineLatest in case obs_1 and obs_2 can emit more than once before completing.

Such an approach would make testing much easier, since testing a service is simpler than testing a Component.

Related