Angular 2+ cached HTTP request force reload

In my angular service I have a cached http request which is called once when my component is initialized

//variables
private readonly URL: string = 'http://makeup-api.herokuapp.com/api/v1/products.json';
private cachingSubject: ReplaySubject<IProduct[]>
private _products: IProduct[] = [];

//method
public getAllProducts(): Observable<IProduct[]> {
    if (!this.cachingSubject) { 
      this.cachingSubject =  new ReplaySubject<IProduct[]>(1);
      this.http.get<IProduct[]>(this.URL)
        .subscribe((productsData: IProduct[]) => {
          this._products = productsData;
          this.cachingSubject.next(productsData);
      });
    }
    return this.cachingSubject.asObservable();
  }

and my component when makes a service method:

public products: IProduct[] = [];
private destroySubject$: Subject<void> = new Subject<void>();
ngOnInit(): void {
  this.productService
    .getAllProducts()
    .pipe(takeUntil(this.destroySubject$))
    .subscribe((products: IProduct[]) => {
      this.products = products;
    })
}
public onForceReload(): void {
  //need to reload onClick 
}

The question is, how can I force reload (reinit cache) from my cached request ? Make getAllProducts again

Upvotes: 4

Views: 1819

Answers (2)

Amer
Amer

Reputation: 6706

You can achieve that like the following:

  • Create an observable cachedRequest$ to assign to it the HTTP request and pipe it with shareReplay, which will cache the response and return it every time you subscribe to the observable.
  • Return the cachedRequest$ if has a value, or assign the HTTP request (with shareReplay) if not.
  • Pass forceReload parameter to your function to force reload, and if it's true, just set the cachedRequest$ to null, to be reinitialized again.

Try the following:

cachedRequest$: Observable<IProduct[]>;

public getAllProducts(forceReload = false): Observable<IProduct[]> {
  if (forceReload) this.cachedRequest$ = null;

  if (!this.cachedRequest$) {
    this.cachedRequest$ = this.http.get<IProduct[]>(this.URL).pipe(
      tap(productsData => (this._products = productsData)),
      shareReplay(1)
    );
  }
  return this.cachedRequest$;
}

Then in your component, it's better to handle the request in a different way to avoid multi subscribe to the source observable, so you can do something like the following:

products$: Observable<IProduct[]>;

ngOnInit(): void {
  this.loadProducts();
}

public onForceReload(): void {
  //need to reload onClick
  this.loadProducts(true);
}

private loadProducts(forceReload = false) {
  this.products$ = this.productService.getAllProducts(forceReload);
}

Then in your component template use it like the following:

<ng-container *ngIf="products$ | async as products">
    <!-- YOUR CONTENT HERE -->
</ng-container>

Upvotes: 1

BizzyBob
BizzyBob

Reputation: 14740

I think the simplest way to achieve what you are looking for is to use a single Subject as a trigger to fetch the data. Then define an allProducts$ observable on the service (instead of a method) that depends on this "fetch subject".

Provide a simple refreshProducts() method that calls .next() on the fetch subject, which will cause allProduct$ to refetch the data.

Consumers can simply subscribe to allProducts$ and receive the latest array of products. They can call refreshProducts() to reload data. There will be no need to reassign references to subjects or observables.

export class ProductService {

  private fetch$ = new BehaviorSubject<void>(undefined);

  public allProducts$: Observable<IProduct[]> = this.fetch$.pipe(
    exhaustMap(() => this.http.get<IProduct[]>('url')),
    shareReplay(),
  );

  public refreshProducts() {
    this.fetch$.next();
  }

}

Here's a working StackBlitz demo.

Here's the flow for allProducts$:

  • Begins with fetch$, meaning it will execute whenever fetch$ emits
  • exhaustMap will subscribe to an "inner observable" and emit its emissions. In this case that inner observable is the http call.
  • shareReplay is used to emit the previous emission to new subscribers so the http call isn't made until refreshProducts() is called (this isn't required if you will only have 1 subscriber at a time, but generally in services, its a good idea to use it)

The reason we define fetch$ as a BehaviorSubject is because allProducts$ will not emit until fetch$ emits. A BehaviorSubject will initially emit a default value, so it causes our allProducts$ to execute without the user needing to click the reload button.

Notice there is no subscription happening in the service. This is a good thing because it allows our data to be lazy, meaning we aren't just fetching just because some component injected the service, we only fetch when there is a subscriber.

Also, this means our service has a unidirectional data flow, which makes things a lot easier to debug. Consumers only get data by subscribing to public observables and they modify the data by calling methods on the service... but these methods do NOT return data, only cause it to be pushed through the observables. There is a really good video on this topic featuring Thomas Burleson (former Angular team member).

You see I used the name allProducts for the exposed observable instead of getAllProducts. Since allProducts$ is an observable, the act of subscribing implies the "get".

I like to think of observable as little data sources that will always push the latest value. When you subscribe, you are listening for future values, but the consumer isn't "getting" them.


I know that was a lot, but I have one more little tid-bit of advice that I think cleans up code quite a bit.

This is the use of the AsyncPipe.

So your component code could be simplified to this:

export class AppComponent  {

  public products$: Observable<IProduct[]> = this.productService.allProducts$;

  constructor(private productService: ProductService) { }

  public onForceReload(): void {
    this.productService.refreshProducts();
  }
  
}

And your template:

<ul>
  <li *ngFor="let product of products$ | async">{{ product }}</li>
</ul>

Notice here there's no need to subscribe in the template just to do this.products = products;. The async pipe subscribes for you, and more importantly unsubscribes for you. This means, you don't need the destroy subject anymore!

Here's a StackBlitz fork of the previous updated to use async pipe.

Upvotes: 4

Related Questions