How can I return an observable with a value that's in a callback?

Viewed 620

I am writing a service that I intend will store local copies of Place objects and fetch them from a back end only when they are not stored locally. However, I am having trouble implementing this functionality. I could set up my page to call fetchPlace() if the value from place() is undefined, but I intend on keeping fetchPlace() private so that I may later implement a system for checking if a request was made recently so that the server isn't flooded with requests if the user rapidly switches pages.

places.service.ts

export class PlacesService {
  private _places = new BehaviorSubject<Place[]>([]);
  get places() {
    return this._places.asObservable();
  }

  constructor(private _http: HttpClient) {}

  place(placeId: number): Observable<Place> {
    return this._places.pipe(
      take(1),
      map((places: Place[]) => {
        console.log(places);
        let place = places.find((place: Place) => place.id === placeId);

        if (place === undefined) {
          console.log('Time to send a request!');
          this.fetchPlace(placeId).subscribe(
            (fetchedPlace: Place) => {
              console.log('We got one!');
              place = fetchedPlace;
              console.log(place);
            },
            (error) => {
              console.error('Looks like a 404.');
            },
          );
        }

        console.log('Okay, returning place now!');
        return place;
      }),
    );
  }

  private fetchPlace(placeId: number): Observable<Place> {
    return this._http
      .get<Place.ResponseBody>(`http://localhost:8000/v1/places/${placeId}/`)
      .pipe(map((response: Place.ResponseBody) => Place.create(response)));
  }
}

The problem with the code above is that when the variable place is undefined, the subscription to fetchPlace() gets called asynchronously, so place is returned before the value of place is overwritten by fetchedPlace. I would like some way of returning an observable containing place from the place() function.

For completion's sake, here is how the code above is called, and the console output:

place-detail.page.ts

ngOnInit() {
  this._route.paramMap.subscribe((paramMap: ParamMap) => {
    if (!paramMap.has('placeId')) {
      this._navCtrl.navigateBack('/places/discover');
      return;
    }

    const placeId = +paramMap.get('placeId');
    this._placesSub = this._placesSrv.place(placeId).subscribe(
      (place: Place) => {
        if (place === undefined) {
          console.log('Got here.');
        } else {
          this._isBookable = place.user !== this._authSrv.user;
          this._place = place;
        }
      },
      (error) => {
        console.error(error);
      }
    );
  });
}

Console

Angular is running in development mode. Call enableProdMode() to enable production mode. core.js:26833
Native: tried calling StatusBar.styleDefault, but Cordova is not available. Make sure to include cordova.js or run in a device/simulator common.js:284
Native: tried calling SplashScreen.hide, but Cordova is not available. Make sure to include cordova.js or run in a device/simulator common.js:284
[WDS] Live Reloading enabled. client:52
Array []
places.service.ts:81:16
Time to send a request! places.service.ts:85:18
Okay, returning place now! places.service.ts:98:16
Got here. place-detail.page.ts:75:20
We got one! places.service.ts:88:22
Object { _id: 1, _user: 2, _title: "Manhattan Mansion", _description: "In the heart of New York City.", _imgUrl: "https://www.idesignarch.com/wp-content/uploads/New-York-Fifth-Avenue-Mansion_1.jpg", _price: "149.99", _availableFrom: Date Fri Dec 31 2021 18:00:00 GMT-0600 (Central Standard Time), _availableTo: Date Sat Dec 30 2023 18:00:00 GMT-0600 (Central Standard Time) }
places.service.ts:90:22
2 Answers

Notice how you're calling subscribe() within the flow of an observable stream? Generally, we want to avoid that because each time an observable emits, you'd be creating a new inner subscription and there isn't really a great way to clean those up.

What you are looking for is a "Higher Order Mapping Operator", in this case switchMap.

Using switchMap, you map an incoming emission to an Observable. Then, switchMap will subscribe to this "inner observable" and emit it's emissions. When a new emission is received, the previous inner observable will be unsubscribed from and the new one will be subscribed. So, essentially, it's allowing you to "switch" an observable source.

In your case, you have two possible sources, either your existing item (place.find(...)) or the result of the new fetch (this.fetchPlace(placeId)).

Since the code in switchMap must return an observable, returning fetchPlace(placeId) is fine, because it returns an observable. However, the existing item is not an observable, so we must wrap with of to turn it into an one.

Here's what your code could look like using switchMap:

place(placeId: number): Observable<Place> {
    return this._places.pipe(
      switchMap((places: Place[]) => {
        const place = places.find(place => place.id === placeId);

        return place ? of(place) : this.fetchPlace(placeId);
      }),
    );
  }

Also, notice I removed take(1). I think you don't want that. Here's why: the main advantage of using observables is that consumers can always be pushed the most current value. take(1) is basically only delivering a single value. So, if you were to implement you cache expiration as you have mentioned, if component A subscribes to place(1) and it becomes stale, then later component B subscribes causing a refetch, you want component A to receive the newly fetched value, right?

Looks like you want to cache API calls, or in RxJs world - share source Observable with future subscribers (instead of subscribing to it again). Here is a clean example:

service.ts:

cache: { [key:string]: Observable<any> } = {};

constructor(private http: HttpClient) {    }

memoisedGet(url: string) {    
  const source = this.http.get(url).pipe( 
    publishReplay(1),
    refCount()
  );
  this.cache[url] = this.cache[url] || source;

  return this.cache[url];
}  

component.ts:

memoisedGet(url: string = 'https://jsonplaceholder.typicode.com/todos/1') {
  this.myService.memoisedGet(url).subscribe(console.log);
}

With this in place, no matter how many times memoisedGet() is called, it will fire one HTTP call and with every call will bring its last result.

Related