Angular 5 caching http service api calls
Asked Answered
G

5

22

In my Angular 5 app a certain dataset (not changing very often) is needed multiple times on different places in the app. After the API is called, the result is stored with the Observable do operator. This way I implemented caching of HTTP requests within my service.

I'm using Angular 5.1.3 and RxJS 5.5.6.

Is this a good practise? Are there better alternatives?

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/of';
import 'rxjs/add/operator/do';

@Injectable()
export class FruitService {

  fruits: Array<string> = [];

  constructor(private http: HttpClient) { }

  getFruits() {
    if (this.fruits.length === 0) {
      return this.http.get<any>('api/getFruits')
        .do(data => { this.fruits = data })
    } else {
      return Observable.of(this.fruits);
    }
  }
}
Gratulation answered 12/4, 2018 at 13:31 Comment(6)
Unrelated to your issue, but if you're using Angular 5, I'd recommend using pipeable operators for your observables instead of the rxjs/add/operator imports.Adaadabel
Related question, #40250129Babirusa
also keep in mind if you use pipe operator, do == tap ;)Incipit
@JoeClay: why is that recommended?Gratulation
See the 'Why?' section of the page I linked for full info, but TL;DR: Pipeable operators are local to your file rather than applied globally to the entire app, they're more easily optimized out by build tools if they're not used, and they allow you to create custom operators much more easily.Adaadabel
@JoeClay: You convinced me, thanks, I have learned something againGratulation
T
17

2022 edit

You can create a cache operator for RxJS and configure it to use any kind of storage you want. Like below it will use, by default, the browser localStorage, but any thing that implements Storage will do, like sessionStorage or you can create your own memoryStorage that uses an inner Map<string, string>.

const PENDING: Record<string, Observable<any>> = {};
const CACHE_MISS: any = Symbol('cache-miss');

export function cache<T>(
  key: string,
  storage: Storage = localStorage
): MonoTypeOperatorFunction<T> {
  return (source) =>
    defer(() => {
      const item = storage.getItem(key);
      if (typeof item !== 'string') {
        return of<T>(CACHE_MISS);
      }
      return of<T>(JSON.parse(item));
    }).pipe(
      switchMap((v) => {
        if (v === CACHE_MISS) {
          let pending = PENDING[key];
          if (!pending) {
            pending = source.pipe(
              tap((v) => storage.setItem(key, JSON.stringify(v))),
              finalize(() => delete PENDING[key]),
              share({
                connector: () => new ReplaySubject(1),
                resetOnComplete: true,
                resetOnError: true,
                resetOnRefCountZero: true,
              })
            );
            PENDING[key] = pending;
          }
          return pending;
        }

        return of(v);
      })
    );
}

Stackblitz example

Old reply

The problem with your solution is that if a 2nd call comes while a 1st one is pending, it create a new http request. Here is how I would do it:

@Injectable()
export class FruitService {

  readonly fruits = this.http.get<any>('api/getFruits').shareReplay(1);

  constructor(private http: HttpClient) { }
}

the bigger problem is when you have params and you want to cache based on the params. In that case you would need some sort of memoize function like the one from lodash (https://lodash.com/docs/4.17.5#memoize)

You can also implement some in-memory cache operator for the Observable, like:

const cache = {};

function cacheOperator<T>(this: Observable<T>, key: string) {
    return new Observable<T>(observer => {
        const cached = cache[key];
        if (cached) {
            cached.subscribe(observer);
        } else {
            const add = this.multicast(new ReplaySubject(1));
            cache[key] = add;
            add.connect();
            add.catch(err => {
                delete cache[key];
                throw err;
            }).subscribe(observer);
        }
    });
}

declare module 'rxjs/Observable' {
    interface Observable<T> {
        cache: typeof cacheOperator;
    }
}

Observable.prototype.cache = cacheOperator;

and use it like:

getFruit(id: number) {
  return this.http.get<any>(`api/fruit/${id}`).cache(`fruit:${id}`);
}
Typhoid answered 12/4, 2018 at 13:34 Comment(6)
cache operator is good POC but it isn't practical. Uncontrollable growth of private cache storage isn't a good thing IRL. There may be other concerns such as maximum capacity, expiration and force refreshing. This should be handled by cache service.Babirusa
Are you really sure this is working? I didn't get it to work for me.Gratulation
@Andrew : not the bigger problem, but the first one. It's not working in my case. I'm using Angular 5.1.3 and RxJS 5.5.6Gratulation
You are right about the problem with my solution if a 2nd call comes while a 1st one is pending.Gratulation
@HermanFransen you mean shareReplay is not working? plnkr.co/edit/pwptj7lKR2FfRw5eVViJ?p=previewRecombination
@Andrew: Sorry I thought .shareReplay(1) was enough, but I need to do exactly what you did and then it worksGratulation
H
8

There is another way doing this with shareReplay and Angular 5, 6 or 7 : create a Service :

    import { Observable } from 'rxjs/Observable';
    import { shareReplay } from 'rxjs/operators';
    const CACHE_SIZE = 1;

    private cache$: Observable<Object>;
    
    get api() {
      if ( !this.cache$ ) {
        this.cache$ = this.requestApi().pipe( shareReplay(CACHE_SIZE) );
      }
      return this.cache$;
    }

    private requestApi() {
      const API_ENDPOINT = 'yoururl/';
      return this.http.get<any>(API_ENDPOINT);
    }

    public resetCache() {
      this.cache$ = null;
    }

To read the data directly in your html file use this :

    <div *ngIf="this.apiService.api | async as api">{{api | json}}</div>

In your component you can subscribe like this:

    this.apiService.api.subscribe(res => {/*your code*/})
Haft answered 19/12, 2018 at 15:59 Comment(2)
What is the benefit of calling pipe() with no arguments?Ajay
Thank you for seeing this. As far as I know, there is no benefit of calling pipe() without argument.Haft
A
3

Actually, the easiest way of caching responses and also sharing a single subscription (not making a new request for every subscriber) is using publishReplay(1) and refCount() (I'm using pipable operators).

readonly fruits$ = this.http.get<any>('api/getFruits')
  .pipe(
    publishReplay(1), // publishReplay(1, _time_)
    refCount(),
    take(1),
  );

Then when you want to get the cached/fresh value you'll just subscribe to fresh$.

fresh$.subscribe(...)

The publishReplay operator caches the value, then refCount maintains only one subscription to its parent and unsubscribes if there are no subscribers. The take(1) is necessary to properly complete the chain after a single value.

The most important part is that when you subscribe to this chain publishReplay emits its buffer on subscription and if it contains a cached value it'll be immediately propagated to take(1) that completes the chain so it won't create subscription to this.http.get at all. If publishReplay doesn't contain anything it'll subscribe to its source and make the HTTP request.

Ambidexter answered 12/4, 2018 at 14:48 Comment(0)
V
3

For Angular 6, RxJS 6 and simple cache expiration use the following code:

interface CacheEntry<T> {
  expiry: number;
  observable: Observable<T>;
}

const DEFAULT_MAX_AGE = 300000;
const globalCache: { [key: string]: CacheEntry<any>; } = {};

export function cache(key: string, maxAge: number = DEFAULT_MAX_AGE) {
  return function cacheOperatorImpl<T>(source: Observable<T>) {
    return Observable.create(observer => {
      const cached = globalCache[key];
      if (cached && cached.expiry >= Date.now()) {
        cached.observable.subscribe(observer);
      } else {
        const add = source.pipe(multicast(new ReplaySubject(1))) as ConnectableObservable<T>;
        globalCache[key] = {observable: add, expiry: Date.now() + maxAge};
        add.connect();
        add.pipe(
          catchError(err => {
            delete globalCache[key];
            return throwError(err);
          })
        ).subscribe(observer);
      }
    });
  };
}
Vivid answered 14/11, 2018 at 20:27 Comment(0)
M
3

Building off of some of the other answers, here's a simple variation for if the API call has parameters (e.g. kind, color).

import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { shareReplay } from 'rxjs/operators';

@Injectable()
export class FruitService {
  private readonly cache: Map<string, Observable<string[]>> =
    new Map<string, Observable<string[]>>();

  constructor(private readonly httpClient: HttpClient) {}

  getFruits(kind: string, color: string): Observable<string[]> {
    const key = `${kind}${color}`;
    if (!this.cache[key]) {
      this.cache[key] = this.httpClient
        .get<string[]>('api/getFruits', { params: { kind, color} })
        .pipe(shareReplay(1));
    }
    return this.cache[key];
  }
}
Maybellmaybelle answered 13/1, 2021 at 16:45 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.