68

I expose an HTTP GET request through a service, and several components are using this data (profile details on a user). I would like the first component request to actually perform the HTTP GET request to the server and cache the results so the the consequent requests will use the cached data, instead of calling the server again.

Here's an example to the service, how would you recommend implementing this cache layer with Angular2 and typescript.

import {Inject, Injectable} from 'angular2/core';
import {Http, Headers} from "angular2/http";
import {JsonHeaders} from "./BaseHeaders";
import {ProfileDetails} from "../models/profileDetails";

@Injectable()
export class ProfileService{
    myProfileDetails: ProfileDetails = null;

    constructor(private http:Http) {

    }

    getUserProfile(userId:number) {
        return this.http.get('/users/' + userId + '/profile/', {
                headers: headers
            })
            .map(response =>  {
                if(response.status==400) {
                    return "FAILURE";
                } else if(response.status == 200) {
                    this.myProfileDetails = new ProfileDetails(response.json());
                    return this.myProfileDetails;
                }
            });
    }
}
Sagi
  • 1,612
  • 5
  • 20
  • 28
  • I think you are looking for [share](https://github.com/Reactive-Extensions/RxJS/blob/master/doc/api/core/operators/share.md).I have a [plnkr](http://plnkr.co/edit/hM4TSt4hlx4DA4xe37WU?p=preview) so you can see it working. Note that this is not caching, but it may work for you :) (Run it once and see the network tab, then remove `.share()` from the `http.get` and see the difference). – Eric Martinez Dec 06 '15 at 10:51
  • I tried your method, but when calling the getUserProfile (with .share()) from two different components, the GET request still gets executed twice on the server. This is though the ProfileService is injected in both constructors of the calling components using @Inject(ProfileService) profileService. What am I missing here? – Sagi Dec 11 '15 at 22:37
  • that depends. If you are injecting the service in each component you are getting two different instances (by injecting I mean using `providers/viewProviers`). If that's the case you should inject it only in your top level component (between those two). If that's not the case you should add more code and a repro if possible. – Eric Martinez Dec 11 '15 at 22:45
  • they all use the same instance of ProfileService (I verified this by putting some private i integer, inceasing it by one each time method is called and printing it to log, it prints 0,1,2... so it means the same instance is used each time). Yet still, for some reason each time the getUserProfile method is called, the GET request is performed once again on the server. – Sagi Dec 11 '15 at 23:00
  • 1
    You're right, I just tried and experienced the same issue. What I found is that using the method to return a `share()` it will return a different share everytime (that kind of makes sense, didn't see it at first). But if you refactor it to make the request in the constructor and assigning it to a variable it will work. **TL;DR** plnkr with the example working : http://plnkr.co/edit/kvha8GH0b9qkw98xLZO5?p=preview – Eric Martinez Dec 11 '15 at 23:14
  • First of all, wow! Nice catch. Indeed it works. You can put it as answer and I will accept, I'm sure it will help other in the future. Second, any ideas if I can also add parameters to this premade GET, as you can see I need to pass userId into my GET request. Thanks! – Sagi Dec 12 '15 at 17:16
  • Is there any way to do this between routes? – sdornan Feb 25 '16 at 00:19

3 Answers3

81

The share() operator works just on the first request, when all the subscriptions are served and you create another one, then it will not work, it will make another Request. (this case is pretty common, as for the angular2 SPA you always create/destroy components)

I used a ReplaySubject to store the value from the http observable. The ReplaySubject observable can serve previous value to its subscribers.

the Service:

@Injectable()
export class DataService {
    private dataObs$ = new ReplaySubject(1);

    constructor(private http: HttpClient) { }

    getData(forceRefresh?: boolean) {
        // If the Subject was NOT subscribed before OR if forceRefresh is requested 
        if (!this.dataObs$.observers.length || forceRefresh) {
            this.http.get('http://jsonplaceholder.typicode.com/posts/2').subscribe(
                data => this.dataObs$.next(data),
                error => {
                    this.dataObs$.error(error);
                    // Recreate the Observable as after Error we cannot emit data anymore
                    this.dataObs$ = new ReplaySubject(1);
                }
            );
        }

        return this.dataObs$;
    }
}

the Component:

@Component({
  selector: 'my-app',
  template: `<div (click)="getData()">getData from AppComponent</div>`
})
export class AppComponent {
    constructor(private dataService: DataService) {}

getData() {
    this.dataService.getData().subscribe(
        requestData => {
            console.log('ChildComponent', requestData);
        },
        // handle the error, otherwise will break the Observable
        error => console.log(error)
    );
}
    }
}

fully working PLUNKER
(observe the console and the Network Tab)

Tiberiu Popescu
  • 4,486
  • 2
  • 26
  • 38
  • Gunter's answer is very helpful, but yours seems even better given that it uses pure RxJS functionality. How difficult to find out about it, but how nice and simple it is once you know what to use (ReplaySubject!). Thanks! :) – LittleTiger Apr 15 '16 at 14:05
  • Thanks, I'm glad that you liked it. I also just edited the post and covered 2 more cases which where not covered: The case when the request fails, now it will make another request and the case when you make many requests in the same time. – Tiberiu Popescu Apr 17 '16 at 00:33
  • very good point, I've learned more about RxJs because of your answer :P but now a curiosity: how to turn it to be lazy loaded? I suppose the `.subscribe(...)` should be replaced by something else. I'm trying to find what should be in place. – Marinho Brandão Sep 05 '16 at 19:06
  • this has a small problem, when your url parameter change the request has to be redone, or say that the best will be to have a cache for the same url – albanx Nov 21 '16 at 23:36
  • You have that **forceRefresh** arg, you can pass that as **True** when you change the URL and it will make a new request. – Tiberiu Popescu Nov 22 '16 at 09:43
  • Does the component have to unsubscribe when it's destroyed? Won't this leak memory every time a component subscribes to `getData()`? – Reactgular Dec 10 '16 at 15:37
  • What is dataObject doing in Service? I can't see it used anywhere. Also, in order to make it work I had to add `() => { this.dataObserver.complete(); }` - finish clause in subscribe, otherwise the requests didn't finish. – Koshmaar Mar 13 '17 at 12:05
  • Do not use **complete()**, this will defeat all the caching purpose, on **complete()** the Subject will became cold therefore cannot emit data anymore. It's true **dataObject** is not used anymore, I removed it. – Tiberiu Popescu Mar 13 '17 at 14:27
  • 1
    Just curious, could you also use a `BehaviorSubject`? Using a `ReplaySubject` with a buffer of 1 seems to equate to the same purpose of a `BehaviorSubject` IMO. The only benefit of a `ReplaySubject` I can see is you do not need an initial value which I guess simplifies things. – keldar Jun 05 '17 at 11:36
  • Note that does won't work when used on route resolving. Like @Koshmaar said, you'll need the call the `complete()` method, which defeats the purpose of caching (like @tibbus explained). – Nicky Jul 05 '17 at 11:28
  • Also, why create a new ReplaySubject in the event of an error? As far as I can tell, calling `this.dataObs$.error(error);` clears observers, but doesn't mark the observable as `complete` (see: https://github.com/ReactiveX/rxjs/blob/master/src/Subject.ts#L66) - thus, you can still subscribe to it in future? I'm not questioning the implementation, just trying to gather a better explanation... :) – keldar Sep 05 '17 at 16:35
  • Actually, ignore the last comment. I've noticed `isStopped = true` as part of the `error(...)` logic, which doesn't stop subscribers, but stops the observable broadcasting data. – keldar Sep 05 '17 at 16:44
  • How can I do that if I want to update/modify the data cached? – Lin Du Sep 26 '17 at 03:18
  • getData(forceRefresh?) has an argument , so if you use it like getData(true).subscribe(...)... then it will re-do the http request and request new data. – Tiberiu Popescu Sep 26 '17 at 09:14
  • if you navigate from component to component it creates new request each time. I have to create extra variable to store last value of ReplaySubject and check if it exists in addition to observers.length. @tibbus can u confirm this problem or I'm doing something wrong? – Victor Bredihin Nov 20 '17 at 21:22
  • looks like this problem only appears with resolvers – Victor Bredihin Nov 20 '17 at 21:29
  • @tibbus can u please post your resolver? For me it looks like when you use resolver with your code .observers.length doesn't change, and since u don't have any subscriptions it's creating new request each time – Victor Bredihin Nov 20 '17 at 21:37
  • I don't use any resolver, but it should not affect the behavior, I saw that the plunker doesn't work anymore as it's too old, will update it soon. – Tiberiu Popescu Nov 21 '17 at 04:32
34

I omitted the userId handling. It would require to manage an array of data and an array of observable (one for each requested userId) instead.

import {Injectable} from '@angular/core';
import {Http, Headers} from '@angular/http';
import {Observable} from 'rxjs/Observable';
import 'rxjs/observable/of';
import 'rxjs/add/operator/share';
import 'rxjs/add/operator/map';
import {Data} from './data';

@Injectable()
export class DataService {
  private url:string = 'https://cors-test.appspot.com/test';

  private data: Data;
  private observable: Observable<any>;

  constructor(private http:Http) {}

  getData() {
    if(this.data) {
      // if `data` is available just return it as `Observable`
      return Observable.of(this.data); 
    } else if(this.observable) {
      // if `this.observable` is set then the request is in progress
      // return the `Observable` for the ongoing request
      return this.observable;
    } else {
      // example header (not necessary)
      let headers = new Headers();
      headers.append('Content-Type', 'application/json');
      // create the request, store the `Observable` for subsequent subscribers
      this.observable = this.http.get(this.url, {
        headers: headers
      })
      .map(response =>  {
        // when the cached data is available we don't need the `Observable` reference anymore
        this.observable = null;

        if(response.status == 400) {
          return "FAILURE";
        } else if(response.status == 200) {
          this.data = new Data(response.json());
          return this.data;
        }
        // make it shared so more than one subscriber can get the result
      })
      .share();
      return this.observable;
    }
  }
}

Plunker example

You can find another interesting solution at https://stackoverflow.com/a/36296015/217408

Community
  • 1
  • 1
Günter Zöchbauer
  • 623,577
  • 216
  • 2,003
  • 1,567
  • 1
    Excellent complete example, even with the this.observable check. The .share() is super important and not easy to figure out without knowing what to look for, at first. Observable.of() is what I was looking for, personally. Now just add a little check so that the request gets repeated if data is older than a certain amount of time :) – LittleTiger Apr 15 '16 at 13:28
  • @Günter : can you share your plunker code of this ? :) – pd farhad May 12 '16 at 04:50
  • @pdfarhad good idea :) Updated the answer. The code contained several bugs that should now be fixed. – Günter Zöchbauer May 12 '16 at 05:50
  • 1
    It took me a while to figure out that `private observable: Observable;` is of type `{}`, what we needed was 'private observable: Observable;' – Stephen Oct 04 '16 at 14:01
  • 2
    This is great! I had to use `import 'rxjs/add/observable/of';` to get it working. – ToastedSoul Apr 18 '17 at 14:14
10

Regarding your last comment, this is the easiest way I can think of : Create a service that will have one property and that property will hold the request.

class Service {
  _data;
  get data() {
    return this._data;
  }
  set data(value) {
    this._data = value;
  }
}

As simple as that. Everything else in the plnkr would be untouched. I removed the request from the Service because it will be instantiated automatically (we don't do new Service..., and I'm not aware of an easy way to pass a parameter through the constructor).

So, now, we have the Service, what we do now is make the request in our component and assign it to the Service variable data

class App {
  constructor(http: Http, svc: Service) {

    // Some dynamic id
    let someDynamicId = 2;

    // Use the dynamic id in the request
    svc.data = http.get('http://someUrl/someId/'+someDynamicId).share();

    // Subscribe to the result
    svc.data.subscribe((result) => {
      /* Do something with the result */
    });
  }
}

Remember that our Service instance is the same one for every component, so when we assign a value to data it will be reflected in every component.

Here's the plnkr with a working example.

Reference

Eric Martinez
  • 31,277
  • 9
  • 92
  • 91
  • Hi, nice example, but it doesn't work for example if you have the request on a click event, it will make a new xhr request every time Ex: http://plnkr.co/edit/Z8amRJmxQ70z9ltBALbk?p=preview (click on the blue square and observe the network tab). In my app I created a new ReplaySubject Observable to cache the HTTP, I want to use the share() method but it's weird why in some cases it doesn't work. – Tiberiu Popescu Apr 04 '16 at 18:33
  • Ah, actually I get it(after some tests and reading the rxjs docs), so it will share the same observable value to all the existing subscriptions, but once there are no subscriptions and you create a new one, then it will request a new value, therefore a new xhr request. – Tiberiu Popescu Apr 04 '16 at 18:56
  • @Eric Martinez : the plunker doesn't run anymore... – George Katsanos Sep 05 '16 at 20:03