RxJS: Caching Observables with a Decorator

Davide Cavaliere - Sep 3 '19 - - Dev Community

Edit: The decorator hereby discussed is now available from npm. Install with npm i @microphi/cache or yarn add @microphi/cache

I have been chasing this one from few months now.
The following should be pretty familiar to you:

@Injectable()
export class UserService {

  constructor(private _client: HttpClient) {}



  public findAll(id: number) {
    return this._client.get(`https://reqres.in/api/users?page=${id}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

For some reason you want to cache the response of this request.
If you look online you may find some tips on how to do it and you may end yourself doing the same thing for all the requests that you want to cache.

It happens though that I was a java developer and remember the old good days when a @Cache annotation would leverage me from a lot of repeated code.

Well in Typescript we have decorator so there must be a way to do some caching with a simple @Cache, right?

My gut feeling was: of course!

But after several attempts with no success I gave up.

Until some days ago when I found this article about caching and refreshing observable in angular by Preston Lamb which re-ignited my curiosity.

Starting from his stackbliz example I did some experiments
but again without any luck.

Until I've got an intuition: let's make a race.

@Injectable()
export class UserService {

  private cached$: ReplaySubject<any> = new ReplaySubject(1, 2500);

  constructor(private _client: HttpClient) {}

  public findAll(id): Observable<any> {

    const req = this._client.get(`https://reqres.in/api/users?page=${id}`).pipe(
      tap((data) => {
        this.cached$.next(data);
      })
    );

    return race(this.cached$, req);
  }

}
Enter fullscreen mode Exit fullscreen mode

Et voila'. It worked! Just how I like it: simple and neat. So simple that I don't even need to explain it, right?

Now the thing is that if in your service you've got many of those methods that you need to cache then you'll need to repeat a lot of code. REM: decorator!

Let's move everything into a decorator

export interface CacheOptions {
  ttl: number;
}

export function Cache(options: CacheOptions) {

  return (target: any, propertyKey: string, descriptor) => {

    const originalFunction = descriptor.value;

    target[`${propertyKey}_cached`] = new ReplaySubject(1, options.ttl);

    descriptor.value = function(...args) {

      const req = originalFunction.apply(this, args).pipe(
        tap((response) => {
          this[`${propertyKey}_cached`].next(response);
        })
      );

      return race(this[`${propertyKey}_cached`], req);

    };

    return descriptor;
  };
}
Enter fullscreen mode Exit fullscreen mode

What I do here is to initialize a variable named, for example, findAll_cached with a replay subject then replace the original function with a new function that will call the original function applying the same logic we saw before.

Then the service will look like the following:

@Injectable()
export class UserService {

  constructor(private _client: HttpClient) {}

  @Cache({
    ttl: 2500
  })
  public findAll(id): Observable<any> {
    return this._client.get(`https://reqres.in/api/users?page=${id}`)
  }

}
Enter fullscreen mode Exit fullscreen mode

So beautiful!

Extra points

Now here it comes my friend that says: yo Davide that's cool but if you call that function with a different argument for sure you need to do the http call again. i.e.: different input different output. Right?

Oh right, that's easy:

export function Cache(options: CacheOptions) {

  let lastCallArguments: any[] = [];

  return (target: any, propertyKey: string, descriptor) => {

    const originalFunction = descriptor.value;

    target[`${propertyKey}_cached`] = new ReplaySubject(1, options.ttl);

    descriptor.value = function(...args) {

      let argsNotChanged = true;

      for (let i = 0; i < lastCallArguments.length; i++) {
        argsNotChanged = argsNotChanged && lastCallArguments[i] == args[i];
      }

      if (!argsNotChanged) { // args change
        this[`${propertyKey}_cached`] = new ReplaySubject(1, options.ttl);
      }

      lastCallArguments = args;

      const req: Observable<any> = originalFunction.apply(this, args).pipe(
        tap((response) => {
          this[`${propertyKey}_cached`].next(response);
        })
      );

      // despite what the documentation says i can't find that the complete is ever called
      return race(this[`${propertyKey}_cached`], req);

    };

    return descriptor;
  };
}
Enter fullscreen mode Exit fullscreen mode

You can play with this code in the following stackbliz and find the complete source code on my github.
Please note that the code on github will probably move to another package in the future.

Caveats

  • If the method that we need to cache makes use of the typescript defaulting mechanism. i.e.:

    
        public findAll(id: number = 1) {
            ...
        }
    

    and then it's called like service.findAll() it happens that the args variable will be [] an empty array as the defaulting takes place only when we call .apply so that in the following example no change of arguments it's detected

    service.findAll()
    
    service.findAll(2)
    
  • Let's look at the example in home.component of the forementioned stackbliz example

    
        setTimeout(() => {
          this.$log.d('starting subscriber');
          this.userService.findAll(1).subscribe((data) => {
            this.$log.d('starting subscribed');
            this.$log.d(data);
            this.users = data;
    
          })
        }, 0);
    
        setTimeout(() => {
          this.$log.d('first subscriber 1 sec later');
          this.userService.findAll(1).subscribe((data) => {
            this.$log.d('first subscribed');
            this.$log.d(data);
    
          })
        }, 1000);
    
        setTimeout(() => {
          this.$log.d('second subscriber 2 sec later');
          this.userService.findAll(1).subscribe((data) => {
            this.$log.d('second subscribed');
            this.$log.d(data);
    
          })
        }, 2000);
    
        setTimeout(() => {
          this.$log.d('third subscriber 3 sec later, ttl expired. shoult hit the endpoint');
          this.userService.findAll(1).subscribe((data) => {
            this.$log.d('third subscribed');
    
            this.$log.d(data);
    
          })
        }, 3000);
    
        setTimeout(() => {
          this.$log.d('fourth subscriber 4 sec later, argument changed. should hit the endpoint');
          this.userService.findAll(2).subscribe((data) => {
    
            this.$log.d(' fourth subscribed');
    
            this.$log.d(data);
          })
        }, 4000);
    
        setTimeout(() => {
          this.$log.d('fifth subscriber 5 sec later, argument changed. should hit the endpoint');
          this.userService.findAll(1).subscribe((data) => {
    
            this.$log.d(' fifth subscribed');
    
            this.$log.d(data);
          })
        }, 5000);
    

    which outputs something like the following on console

    
    [...]
    third subscriber 3 sec later, ttl expired. shoult hit the endpoint
    arguments are
    [1]
    argsNotChanged
    true
    this actually hit the endpoint
    starting subscribed
    {page: 1, per_page: 6, total: 12, total_pages: 2…}
    first subscribed
    {page: 1, per_page: 6, total: 12, total_pages: 2…}
    second subscribed
    {page: 1, per_page: 6, total: 12, total_pages: 2…}
    third subscribed
    {page: 1, per_page: 6, total: 12, total_pages: 2…}
    [...]
    

    As you can see when we subscribe again after the cache is expired all previous subscriptions are notified again.

Thanks for reading so far I hope you enjoyed and remember: if you like this article share it with your friends, if you don't keep it for yourself ;)

(2023/05/03) Edit: Update github repo link

. . . . . . .
Terabox Video Player