Signal-based utility keeping track of multiple async operations performed with observables

40 views Asked by At

I need a Signal-based utility to keep track of the status of multiple async operations performed with observables (e.g. HTTP calls). So I can use those signals also in Components using the OnPush change detection strategy.

Suppose I have an AnimalService that you use to fetch animals:

@Injectable({
  providedIn: 'root',
})
export class AnimalService {
  private readonly httpClient = inject(HttpClient);

  fetchCats() {
    return this.httpClient.get('animals.org/cats');
  }

  fetchDogs() {
    return this.httpClient.get('animals.org/dogs');
  }

  fetchChickens() {
    return this.httpClient.get('animals.org/chickens')
  }
}

I want to have a centralised place that keeps track of when some animals are being loaded. Also, I want to track this from places that might not even be aware of where the actual calls are performed. For example, suppose Component A triggers the fetch calls, but Component B wants actually to know whether or not there are animals being loaded to display a spinner.

1

There are 1 answers

0
Francesco Borzi On

I solved this issue by creating a generic LoadingStatusManager utility that looks like this:

import { computed, Signal, signal, WritableSignal } from '@angular/core';
import { catchError, Observable, tap } from 'rxjs';

export class LoadingStatusManager<KeyType extends string> {
  private readonly statusMap = {} as { [key in KeyType]: WritableSignal<boolean> };

  constructor(private readonly keys: KeyType[]) {
    for (const key of keys) {
      this.statusMap[key] = signal(false);
    }
  }

  executeAndUpdateStatus<T>(key: KeyType, observable: Observable<T>): Observable<T> {
    this.statusMap[key].set(true);
    return observable.pipe(
      tap(() => this.statusMap[key].set(false)),
      catchError((error) => {
        this.statusMap[key].set(false);
        throw error;
      }),
    );
  }

  getSignalWatchingOnProps(keys?: KeyType[]): Signal<boolean> {
    const keySet = keys ?? this.keys;
    return computed(() => keySet.some((key) => this.statusMap[key]()));
  }
}

To use it, first of all, you define a type that defines one key (string) per each call that you want to track:

type AnimalLoadingStatusKeyType = 'fetchCats' | 'fetchDogs' | 'fetchChickens';

Then you create a new LoadingStatusManager object using this type:

private readonly loadingStatusManager = new LoadingStatusManager<AnimalLoadingStatusKeyType>([
  'fetchCats',
  'fetchDogs',
  'fetchChickens',
]);

Now use the loadingStatusManager object to watch your async operations:

fetchCats() {
  return this.loadingStatusManager.executeAndUpdateStatus(
    'fetchCats',
    this.httpClient.get('animals.org/cats'),
  );
}

fetchDogs() {
  return this.loadingStatusManager.executeAndUpdateStatus(
    'fetchDogs',
    this.httpClient.get('animals.org/dogs'),
  );
}

fetchChickens() {
  return this.loadingStatusManager.executeAndUpdateStatus(
    'fetchChickens',
    this.httpClient.get('animals.org/chickens'),
  );
}

Now you can easily get a Signal that watches if any of the async operations are in progress:

areAnimalsBeingLoaded: Signal<boolean> = this.loadingStatusManager.getSignalWatchingOnProps();

This is because we did not pass any key to getSignalWatchingOnProps, so by default it watches on all keys.

If we want to get a Signal that is watching only on a specific set of keys, we can use:

areMammalsBeingLoaded: Signal<boolean> = this.loadingStatusManager.getSignalWatchingOnProps([
  'fetchCats',
  'fetchDogs',
]);

The complete code of the AnimalService is:

import { HttpClient } from '@angular/common/http';
import { inject, Injectable, Signal } from '@angular/core';

import { LoadingStatusManager } from './loading-status-manager';

type AnimalLoadingStatusKeyType = 'fetchCats' | 'fetchDogs' | 'fetchChickens';

@Injectable({
  providedIn: 'root',
})
export class AnimalService {
  private readonly httpClient = inject(HttpClient);

  private readonly loadingStatusManager = new LoadingStatusManager<AnimalLoadingStatusKeyType>([
    'fetchCats',
    'fetchDogs',
    'fetchChickens',
  ]);

  readonly areAnimalsBeingLoaded: Signal<boolean> = this.loadingStatusManager.getSignalWatchingOnProps();
  readonly areMammalsBeingLoaded: Signal<boolean> = this.loadingStatusManager.getSignalWatchingOnProps([
    'fetchCats',
    'fetchDogs',
  ]);

  fetchCats() {
    return this.loadingStatusManager.executeAndUpdateStatus(
      'fetchCats',
      this.httpClient.get('animals.org/cats'),
    );
  }

  fetchDogs() {
    return this.loadingStatusManager.executeAndUpdateStatus(
      'fetchDogs',
      this.httpClient.get('animals.org/dogs'),
    );
  }

  fetchChickens() {
    return this.loadingStatusManager.executeAndUpdateStatus(
      'fetchChickens',
      this.httpClient.get('animals.org/chickens'),
    );
  }
}

From outside, any Component or Service can inject the AnimalService and get the signals:

readonly animalService: inject(AnimalService);
// ...

this.animalService.areAnimalsBeingLoaded();

this.animalService.areMammalsBeingLoaded();

This can be used also in the template of a Component using OnPush change detection strategy. Thanks to the Angular Signals, the template will be refreshed automatically each time the value of the signals change.