I would like to cache the result of a HTTP
-request in an Observable
that is provided by a class. Additionally I must be able to invalidate the cached data explicitly. Because every call to subscribe()
on an Observable
that was created by HttpClient
triggers a new request, re-subscribing seemed to be the way to go for me. So I ended up with the following service:
import { Injectable } from '@angular/core'
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { shareReplay, first } from 'rxjs/operators';
@Injectable()
export class ServerDataService {
public constructor(
private http: HttpClient
) { }
// The request to retrieve all foos from the server
// Re-issued for each call to `subscribe()`
private readonly requestFoos = this.http.get<any[]>("/api/foo")
// Cached instances, may be subscribed to externally
readonly cachedFoos = this.requestFoos.pipe(shareReplay(1));
// Used for illustrating purposes, even though technically
// ngOnInit is not automatically called on Services. Just
// pretend this is actually called at least once ;)
ngOnInit() {
this.cachedFoos.subscribe(r => console.log("New cached foos"));
}
// Re-issues the HTTP request and therefore triggers a new
// item for `cachedFoos`
refreshFoos() {
this.requestFoos
.pipe(first())
.subscribe(r => {
console.log("Refreshed foos");
});
}
}
When calling refreshFoos
I expect the following things to happen:
HTTP
-request is made, this happens!"Refreshed foos"
is printed, this happens!"New cached foos"
is printed, this does not happen! And therefore my cache is not validated and the UI that subscribed to cachedFoos
using an async
-pipe is not updated.I am aware that, because step 2 works, I could probably hack together a manual solution by using an explicit ReplaySubject
and calling next
manually on that instead of printing to the console. But this feels hacky and I would expect that there is a more "rxjsy-way" to do this.
And this brings me to two closely related questions:
cachedFoos
subscription not updated when the underlying requestFoos
is triggered?refreshFoos
-variant that, preferably using only RxJS, would update all subscribers of cachedFoos
?I ended up introducing a dedicated class CachedRequest
which allows to re-subscribe to any Observable
. As a bonus, the class below can also inform the outside world whether a request is currently made or not, but that functionality comes with a huge comment because Angular (rightfully) chokes on side effects in Template expressions.
/**
* Caches the initial result of the given Observable (which is meant to be an Angular
* HTTP request) and provides an option to explicitly refresh the value by re-subscribing
* to the inital Observable.
*/
class CachedRequest<T> {
// Every new value triggers another request. The exact value
// is not of interest, so a single valued type seems appropriate.
private _trigger = new BehaviorSubject<"trigger">("trigger");
// Counts the number of requests that are currently in progress.
// This counter must be initialized with 1, even though there is technically
// no request in progress unless `value` has been accessed at least
// once. Take a comfortable seat for a lengthy explanation:
//
// Subscribing to `value` has a side-effect: It increments the
// `_inProgress`-counter. And Angular (for good reasons) *really*
// dislikes side-effects from operations that should be considered
// "reading"-operations. It therefore evaluates every template expression
// twice (when in debug mode) which leads to the following observations
// if both `inProgress` and `value` are used in the same template:
//
// 1) Subscription: No cached value, request count was 0 but is incremented
// 2) Subscription: WAAAAAH, the value of `inProgress` has changed! ABORT!!11
//
// And then Angular aborts with a nice `ExpressionChangedAfterItHasBeenCheckedError`.
// This is a race condition par excellence, in theory the request could also
// be finished between checks #1 and #2 which would lead to the same error. But
// in practice the server will not respond that fast. And I was to lazy to check
// whether the Angular devs might have taken HTTP-requests into account and simply
// don't allow any update to them when rendering in debug mode. If they were so
// smart they have at least made this error condition impossible *for HTTP requests*.
//
// So we are between a rock and a hard place. From the top of my head, there seem to
// be 2 possible workarounds that can work with a `_inProgress`-counter that is
// initialized with 1.
//
// 1) Do all increment-operations in the in `refresh`-method.
// This works because `refresh` is never implicitly triggered. This leads to
// incorrect results for `inProgress` if the `value` is never actually
// triggered: An in progress request is assumed even if no request was fired.
// 2) Introduce some member variable that introduces special behavior when
// before the first subscription is made: Report progress only if some
// initial subscription took place and do **not** increment the counter
// the very first time.
//
// For the moment, I went with option 1.
private _inProgress = new BehaviorSubject<number>(1);
constructor(
private _httpRequest: Observable<T>
) { }
/**
* Retrieve the current value. This triggers a request if no current value
* exists and there is no other request in progress.
*/
readonly value: Observable<T> = this._trigger.pipe(
//tap(_ => this._inProgress.next(this._inProgress.value + 1)),
switchMap(_ => this._httpRequest),
tap(_ => this._inProgress.next(this._inProgress.value - 1)),
shareReplay(1)
);
/**
* Reports whether there is currently a request in progress.
*/
readonly inProgress = this._inProgress.pipe(
map(count => count > 0)
);
/**
* Unconditionally triggers a new request.
*/
refresh() {
this._inProgress.next(this._inProgress.value + 1);
this._trigger.next("trigger");
}
}