Home > Software design >  How to deal with late subscription in angular
How to deal with late subscription in angular

Time:07-29

Let's say I have two observables to be consumed by the template, one with the data and one as a loading indicator:

public readonly data$ = this.backend.get<Data>(...).pipe(
  finalize((
    {
      this.isLoadingSubject$.next(1); 
      return () => this.isLoadingSubject$.next(-1);
    })()
);
private readonly isLoadingSubject$ = new Subject<number>();
public readonly isLoading$ = isLoadingSubject$.pipe(
    scan((acc, value) => acc   value, 0),
    map((i) => i > 0),
);

Explanation on the finalize: I'm using an IIFE to start loading when the pipeline of data$ gets triggered, and to end loading when it completes. I am using a number that gets incremented or decremented instead of a boolean because there might be multiple simultaneous requests (the isLoading$ mechanism is used by many observable pipelines).

In my template I use it like this:

<ng-container *ngIf="data$ | async as data">
  {{ data }}  
<button [disabled]="isLoading$ | async">some button</button>
</ng-container>

The problem is that the subscription to isLoading$ is late: this subscription only happens once data$ has emitted, and the first .next( 1) gets ignored because there are no subscribers.

How do I solve this elegantly?

Workarounds I have tried and that I do not like:

  • Subscribing to isLoading$ immediately to make it hot - seems wasteful, and when reading the code, it's not apparent why this is done. Because of that this seems like a bad workaround if it's not clear what it is for from the code alone.
  • Rearranging the template so that isLoading$ is in a first <ng-container> and then data$ in a second <ng-container> - but then I have to deal with *ngIf not rendering the template when loading is false, so I have to wrap it in an object, which seems wasteful again. And also, this causes everything to be re-rendered every time the loading toggles, which is stupid.
  • Looked at the publishReplay() operator, but that's deprecated.
  • Wrapping both data$ and isLoading$ in an object inside the same <ng-container>, but then the whole template gets re-rendered whenever the loading indicator changes, this is super wasteful - I only want to disable a button.

CodePudding user response:

I think the cleanest way to handle this is to use your "workaournd #4"; put both pieces of data into a single object (AKA a view model. This pattern is explained by Sander Elias in this video):

const INITIAL_STATE = {
  data      : undefined,
  isLoading : false,
};

@Component()
class MyComponent {
  private isLoadingSubject$ = new Subject<number>();

  private isLoading$ = isLoadingSubject$.pipe(
    scan((acc, value) => acc   value, 0),
    map(i => i > 0),
  );

  public vm$ = combineLatest({
    data      : this.getData()  .pipe(startWith(INITIAL_STATE.data)),
    isLoading : this.isLoading$ .pipe(startWith(INITIAL_STATE.isLoading))
  });

  private getData() {
    this.isLoadingSubject.next(1);

    return this.backend.get<Data>(...).pipe(
      finalize(() => this.isLoadingSubject.next(-1))
    );
  }
}
<ng-container *ngIf="vm$ | async as vm">
  {{ vm.data }}  
  <button [disabled]="vm.isLoading">some button</button>
</ng-container>

No need to worry about:

but then the whole template gets re-rendered whenever the loading indicator changes

This concern is mentioned at this part in the previously mentioned video.

While it is true change detection will run on the component whenever any part of the combined view model changes, only elements that actually have changes will be rerendered; So if only the isLoading property changes, only the button would be updated in the UI. The binding to data would not be affected, because it hasn't changed.

  • Related