DEV Community

loading...

RxJS: Caching Observables with a Decorator

davidecavaliere profile image Davide Cavaliere Updated on ・4 min read

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}`);
  }
}

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);
  }

}

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;
  };
}

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}`)
  }

}

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;
  };
}

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 ;)

Discussion (11)

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 Author

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 Author

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 Author

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

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 Author

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

Forem Open with the Forem app