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;
                }
            });
    }
}
7
  • I think you are looking for share.I have a plnkr 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). Commented Dec 6, 2015 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? Commented Dec 11, 2015 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. Commented Dec 11, 2015 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. Commented Dec 11, 2015 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 : plnkr.co/edit/kvha8GH0b9qkw98xLZO5?p=preview Commented Dec 11, 2015 at 23:14

3 Answers 3

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)

Sign up to request clarification or add additional context in comments.

18 Comments

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! :)
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.
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.
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
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.
|
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

5 Comments

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 :)
@Günter : can you share your plunker code of this ? :)
@pdfarhad good idea :) Updated the answer. The code contained several bugs that should now be fixed.
It took me a while to figure out that private observable: Observable; is of type {}, what we needed was 'private observable: Observable<any>;'
This is great! I had to use import 'rxjs/add/observable/of'; to get it working.
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

3 Comments

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: 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.
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.
@Eric Martinez : the plunker doesn't run anymore...

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.