Fetch data once with Observables in Angular 2

16,673

Solution 1

1) You can simply save downloaded data in your service:

export class CustomersService {
  protected _customers: Array<Customer>;

  constructor(public http: Http) {}

  public getCustomers(): Observable<Array<Customer>> {
    return new Observable(observer => {
      if (this._customers) {
        observer.next(this._customers);
        return observer.complete();
      }
      this.http
        .get(this.baseURI + this.url)
        .map((r: Response) => (r.json() as Array<Customer>))
        .subscribe((customers: Array<Customer>) => {
          this._customers = customers;
          observer.next(this.customers);
          observer.complete();
        });
    });
  }
}

2) Shorter approach taking refresh parameter:

export class CustomersService {
  protected _customers: Array<Customer>;

  constructor(public http: Http) {}

  public getCustomers(refresh?: boolean): Observable<Array<Customer>> {
    if (!refresh && this._customers) {
      return Observable.of(this._customers);
    }
    return this.http
            .get(this.baseURI + this.url)
            .map((c: Response) => (c.json() as Array<Customer>))
            .do((customers: Array<Customer>) => {
                this._customers = customers;
            });
    });
  }
}

3) Taking advantage of ReplaySubject:

export class CustomersService {
  protected _customers$: ReplaySubject<Array<Customer>> = new ReplaySubject(1);
  protected _customersInitialized: boolean;

  constructor(public http: Http) {}

  public getCustomers(refresh?: boolean): Observable<Array<Customer>> {
    if (refresh || !this._customersInitialized) {
      this._customersInitialized = true;
      this.http
        .get(this.baseURI + this.url)
        .map((c: Response) => (c.json() as Array<Customer>))
        .subscribe((customers: Array<Customer>) => {
          this._customers$.next(customers);
        });
    }
    return this._customers$.asObservable().skip(+refresh).distinctUntilChanged();
  }
}

And then:

this.customersService.getCustomers()
    .subscribe(customers => this.customers = customers);

You can also expose the always up-to-date customers field from SomeService for read only purposes (like displaying in the templates) this way:

public get customers(): ReadonlyArray<Customer> {
  return this._customers;
}

Solution 2

I would create a parent container, fetch the data once, and pass it to child components using @Input.

Parent:

@Component({
    selector: 'BarFooHttpCaller',
    template: ´<child *ngIf="data.length > 0" [data]></child>´
})

class BarFooHttpCaller {
    private data: any;
    constructor(private foobar:Foobar) {
        this.data = {};
    }

    ngOnInit() {
        this.foobar.getCustomers().subscribe(() => {       
            console.log('httpdone') 
        });
        this.foobar.dataStream.subscribe((data) => {
            console.log('new data', data);
            this.data = data;
        })
    }
}

Child:

import { Component, Input } from '@angular/core';

@Component({
    selector: 'child',
    template: ´<div>{{data}}</div>´
})

export class Child {
    @Input() data: any;

}

Solution 3

If you want multiple children to subscribe to the same observable, but only execute the observable once you can do the following.

Note that this does adhere to the design of observables since we are executing the observable in the service layer (Observable.fromPromis(stream.toPromise()) when execution should be done from the component subscribing. View https://www.bennadel.com/blog/3184-creating-leaky-abstractions-with-rxjs-in-angular-2-1-1.htm for more.

  //declare observable to listen to
  private dataObservable: Observable<any>;

  getData(slug: string): Observable<any> {

    //If observable does not exist/is not running create a new one
    if (!this.dataObservable) {

        let stream = this.http.get(slug + "/api/Endpoint")
            .map(this.extractData)
            .finally(() => {
                //Clear the observable now that it has been listened to
                this.staffDataObservable = null;
            });

        //Executes the http request immediately
        this.dataObservable = Observable.fromPromise(stream.toPromise());

    }        

    return this.staffDataObservable;
 }

Solution 4

the share operator give the possibility to use the same stream's result with multiple observers. It could be good but you generate a new observable stream each time you call getCustomers(), there is no point to call share() since you didn't subscribe multiple times to this stream.

If you wanna share the data with multiple observers but make only one http call you simply have to create a second stream, feed by the http one, containing the data. After that, all your components could subscribe to it.

The code could be something like that

@Injectable()
class FooBar {

    public dataStream:Subject<any> = new Subject();

    constructor(private http:Http) {}

    public getCustomers() {
        return this.http
        .get(this.baseURI + this.url)
        .map((response:Response) => response.json())
        .map((data) => {
            this.dataStream.next(data); 
            return data;
        })
    }

}


@Component({})
class BarFooHttpCaller {
    constructor(private foobar:Foobar) {}

    ngOnInit() {
        this.foobar.getCustomers().subscribe(() => { console.log('http done') });
        this.foobar.dataStream.subscribe((data) => {
            console.log('new data', data);
        })
    }
}

@Component({})
class OtherBarFoo {
    constructor(private foobar:Foobar) {}

    ngOnInit() {
        this.foobar.dataStream.subscribe((data) => {
            console.log('new data', data);
        })
    }
}
Share:
16,673
David
Author by

David

Updated on June 07, 2022

Comments

  • David
    David almost 2 years

    I have a service, what is used several times from a lot of my Angular 2 components. It fetches customer data from a Web API and returns an Observable:

    getCustomers() {
       return this.http
            .get(this.baseURI + this.url)
            .map((r: Response) => {
                let a = r.json() as Customer[];                       
                return a;                         
            });               
    }    
    

    I inject this service in my root component, and in every component that wants to access the customers I just subscribe to that Observable:

    this.customerService.getCustomers().subscribe(v => this.items = v);
    

    However, every component who subscribes to my Observable causes another execution of the HTTP-request. But to fetch the data only once is enough. If I try share(), it does not solve my problem:

    getCustomers() {
       return this.http
            .get(this.baseURI + this.url)
            .map((r: Response) => {
                let a = r.json() as Customer[];                       
                return a;                         
            }).share();               
    }   
    

    Still the same issue. Any proposals which operators I have to use to only fetch data once?

  • David
    David over 7 years
    Okay, but in this case I always have to start at the component, that calls getCustomers().subscribe(...). In my application I can never know which component is accessed first (a user might enter another URL than another user). When I call the subscription in the service itself it does not really work. How should I modify your example in this case?
  • Polochon
    Polochon over 7 years
    You have many possibilities, store the data outside the dataStream and verify that it's already feed and if so, return the dataStream instead of the http one. You can also make a simple debounce that can prend multiple call to getCustomers() so you could avoid to take care of the concurrency. An other method would be to call the method in your top level route's component and just subscribe in the childs.
  • David
    David over 7 years
    With a little modification this works. You have to keep in mind, that inside your promise only an observable is created, but not subscribed to. So your solution only works if you subscribe to this created observable and call "resolve..." inside the subscription.
  • Nick De Beer
    Nick De Beer almost 7 years
    @David how did you go about doing this?
  • A T
    A T over 6 years
    I usually do the whole this.customers? Observable.of(this.customers) : this.http… but it's good to see this more standard approach
  • Maverick
    Maverick over 6 years
    After going down the ReplaySubject rabbit hole, I think the solution you want follows the BehaviorSubject pattern better.
  • Daniel Kucal
    Daniel Kucal over 6 years
    @Maverick, I've added the code for approach using ReplaySubject. I'm sure it's also doable using BehaviorSubject, but I prefer to not run subscribers until I get the real data.
  • danwellman
    danwellman about 6 years
    Great answer, nice to include alternative approaches
  • Taul
    Taul over 5 years
    In part 3 with the ReplaySubject: How does getCustomers() return an Observable when the conditional statement is true? My typescript compiler says the return type of .subscribe() is not Observable<Array<Customer>>. Am I missing something?
  • Daniel Kucal
    Daniel Kucal over 5 years
    Thanks for sharing your observation. There was redundant "return" inside the "if" statement, corrected now.
  • Phil Peace
    Phil Peace almost 2 years
    What if this subscribed to twice before the HTTP request returns. Will the HTTP request be called twice?
  • Phil Peace
    Phil Peace almost 2 years
    What if this subscribed to twice before the HTTP request returns. Will the HTTP request be called twice?