DEV Community

Davide Cavaliere
Davide Cavaliere

Posted on • Edited on

RxJS: Caching Observables with a Decorator

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

Top comments (13)

Collapse
 
geddard profile image
Javier Baccarelli

Looks fancy but it doesn't work for me :/ with th first block (without caring about the args) only the first call works, then nothing.
But when i update to care about different args, i just call the endpoint each time, even with the same args...

Collapse
 
davidecavaliere profile image
Davide Cavaliere

Sorry for this late reply but somehow i missed your comment. The decorator is now available through npm please try to use it from @microphi/cache. If it still doesn't work please open an issue here issues

Collapse
 
geddard profile image
Javier Baccarelli

I see that sometimes you use target[${propertyKey}_cached] and others this[${propertyKey}_cached]. Is that an error?
I tried tweaking that and it still fails

Collapse
 
sysmat profile image
Sysmat

For me this doesn't work(@microphi/cache) it fetch only one id ok

Collapse
 
davidecavaliere profile image
Davide Cavaliere

If you share your code maybe I can help.

Collapse
 
sysmat profile image
Sysmat
  • service.ts:
@Cache({
    ttl: 250
  })
  getUserNetIdById(id: number): Observable<string> {
    return this.http.get<AaiUser>(`${userPath}/${id}`, getHttpJwtOptions()).pipe(map(u => u.netId));
  }
Enter fullscreen mode Exit fullscreen mode
  • pipe.ts:
@Pipe({
  name: 'getUserById'
})
export class GetUserByIdPipe implements PipeTransform {

  constructor(
    private readonly userService: UserService
  ) { }

  transform(id: number): Observable<string> {
    return this.userService.getUserNetIdById(id);
  }
}
Enter fullscreen mode Exit fullscreen mode
  • template.html
<tr *ngFor="let docSpec of docSpecs">
<td>
      <small>
            {{docSpec.user}}
            {{docSpec.user | getUserById | async}}
       </small>
</td>
</tr>
Enter fullscreen mode Exit fullscreen mode

- wrong result in the template:

and for userId=1, 17 I get some result but should be different

- img :

Regards, Tomaž

Thread Thread
 
Sloan, the sloth mascot
Comment deleted
 
sysmat profile image
Sysmat

hm I can't upload image

Thread Thread
 
davidecavaliere profile image
Davide Cavaliere

Maybe a silly question but if you remove the @Cache decorator, do you actually get what you expect?
Is there anyway you can share the project so that i can run it?

Thread Thread
 
sysmat profile image
Sysmat • Edited
  • yes of cource it is in production
  • I implement simple in memory object as cache
  • no it use a lot of services
Thread Thread
 
davidecavaliere profile image
Davide Cavaliere

If you can please open an issue here with the steps to reproduce the problem.

Collapse
 
ex0dus profile image
Rory

The second example works perfectly. Btw, can you fix the broken links in the post? I couldn't find a GitHub repo

Collapse
 
davidecavaliere profile image
Davide Cavaliere

Thanks for point that up. I fixed it.