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);
}
}
}
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?
Angular provides @angular/common/http library for communicating with a remote server over HTTP. We will call Http Web Services (REST Services) using HttpClient. To make HttpClient available everywhere in the app, open the root AppModule, import the HttpClientModule symbol from @angular/common/http.
So let’s start… the first step is to enable HTTP services library for HTTP calls. If you are using Angular 2 or 4, read HTTP Calling using Angular 2 or 4. library for communicating with a remote server over HTTP. We will call Http Web Services (REST Services) using HttpClient.
If you are using Angular 2 or 4, read HTTP Calling using Angular 2 or 4. library for communicating with a remote server over HTTP. We will call Http Web Services (REST Services) using HttpClient. To make HttpClient available everywhere in the app, . array. ... ... ... ...
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
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}`);
}
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*/})
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With