2

I'm trying to send the result of HttpClient post requests multiple components in my Angular app. I'm using a Subject and calling its next() method whenever a new post request is successfully executed. Each component subscribes to the service's Subject.

The faulty services is defined as

@Injectable()
export class BuildingDataService {

  public response: Subject<object> = new Subject<object>();

  constructor (private http: HttpClient) { }

  fetchBuildingData(location) {
    ...

    this.http.post(url, location, httpOptions).subscribe(resp => {
      this.response.next(resp);
    });
}

The components subscribe to BuildingService.response as follows

@Component({
  template: "<h1>{{buildingName}}</h1>"
  ...
})

export class SidepanelComponent implements OnInit {
  buildingName: string;

  constructor(private buildingDataService: BuildingDataService) { }

  ngOnInit() {
    this.buildingDataService.response.subscribe(resp => {
        this.buildingName = resp['buildingName'];
      });
  }

  updateBuildingInfo(location) {
    this.buildingDataService.fetchBuildingData(location);
  }
}

updateBuildingInfo is triggered by users clicking on a map.

Retrieving the data from the server and passing it to the components works: I can output the payloads to the console in each component. However, the components' templates fail to update.

After Googling and fiddling for most of today I found that this implementation does not trigger Angular's change detection. The fix is to either

  • wrap my call to next() in the service in NgZone.run(() => { this.response.next(resp); }
  • call ApplicationRef.tick() after this.title = resp['title'] in the component.

Both solutions feel like dirty hacks for such a trivial use case. There must be a better way to achieve this.

My question therefore is: what is the proper way to fetch data once and send it off to several components?

I'd furthermore like to understand why my implementation escapes Angular's change detection system.

EDIT it turns out I was initiating my call to HttpClient outside of Angular's zone hence it could not detect my changes, see my answer for more details.

Simeon Nedkov
  • 1,097
  • 7
  • 11

5 Answers5

4

One way is to get an Observable of the Subject and use it in your template using async pipe:

(building | async)?.buildingName

Also, if different components are subscribing to the service at different times, you may have to use BehaviorSubject instead of a Subject.


@Injectable()
export class BuildingDataService {
  private responseSource = new Subject<object>();
  public response = this.responseSource.asObservable()

  constructor (private http: HttpClient) { }

  fetchBuildingData(location) {
    this.http.post(url, location, httpOptions).subscribe(resp => {
      this.responseSource.next(resp);
    });
  }
}

@Component({
  template: "<h1>{{(building | async)?.buildingName}}</h1>"
  ...
})

export class SidepanelComponent implements OnInit {
  building: Observable<any>;

  constructor(private buildingDataService: DataService) {
      this.building = this.buildingDataService.response;
  }

  ngOnInit() {
  }

  updateBuildingInfo(location) {
    this.buildingDataService.fetchBuildingData(location);
  }
}
Roberto Zvjerković
  • 9,657
  • 4
  • 26
  • 47
sabithpocker
  • 15,274
  • 1
  • 42
  • 75
  • Thanks for the quick reply! Unfortunately, the `async` pipe produces the same effect: it only works when I trigger a manual change detection preferably through NgZone or else it lags one tick i.e. it displays the previous value instead of the current. I'll research BehaviourSubject, thanks for pointing it out. – Simeon Nedkov Apr 25 '18 at 21:30
  • Also make sure you are unsubscribing the subscriptions made manually. I don't really see any need for manual change detection in this scenario. – sabithpocker Apr 26 '18 at 05:55
  • Also, you are injecting `DataService` instead of `BuildingDataService` is that a typo? – sabithpocker Apr 26 '18 at 06:01
  • That's a typo; fixed now. I'm reaching for manual change detection since the template does not update upon arrival of a new value through the Subject (or BehaviourSubject for that matter, see my comments in DiabolicWord's answer. – Simeon Nedkov Apr 26 '18 at 06:55
  • 1
    I found the issue: I'm triggering `fetchBuildingData` from a callback outside of Angular's zone. Thanks a lot for the different approaches; they helped me zone in on the problem. Your remark that you "don't really see any need for manual change detection in this scenario" somehow reminded me to look at how I call `fetchBuildingData`. – Simeon Nedkov Apr 26 '18 at 08:45
  • I accepted your answer as it's the closest to the sought solution. There's a missing `this` in the fourth line of the first code block, though. Can you add it? – Simeon Nedkov Apr 30 '18 at 06:58
  • This answer has nothing to do with your question. You should accept your own answer not to confuse future readers. – Roberto Zvjerković Apr 30 '18 at 07:34
  • I was unsure what to do as the other answers directly address the question about multicasting whereas my solution fixes something completely unrelated. I'd hate someone to Google for multicasting and end up on a page describing how to fix something with Leaflet. Still unsure what to, so please advise. – Simeon Nedkov Apr 30 '18 at 10:03
1

The standard way to do this in an angular app is a getter in the service class.

get data()
{
     return data;
}

Why are you trying to complicate matters? What are the benefits, and if there are benefits they will overcome the drawbacks?

Giona Granata
  • 549
  • 5
  • 11
  • I want to push data to component A which is retrieved due to an user interacting with component B. I don't see how that can be done through getters as I'd have to call them explicitly from component A, right? – Simeon Nedkov Apr 26 '18 at 07:26
  • No you do not call them directly on component A. You inject the same Singleton service in both. You start retrivi ng data by calling a method on the singleton (ferchBuildingData). This method save the data in a member of the singleton. The member of the singleton is shared across any component with a getter. This is standard practice in angular, you can see this in the hero tutorial in the official documentation. An other elegant way to solve this problem is to use the redux library for angular, so implementing a state management (I like this a lot). – Giona Granata Apr 26 '18 at 07:48
  • Yes, you're right, this works like a charm. I wasn't aware of the fact that getters are bound to values! This is indeed much simpler than the Subject/Observable approach. – Simeon Nedkov Apr 26 '18 at 09:01
  • Can you point me to the docs where `get` is mentioned? I can't find it... – Simeon Nedkov Apr 26 '18 at 09:10
1

I think I found the issue. I briefly mention that fetchBuildingData is triggered by users clicking on a map; that map is Leaflet as provided by the ngx-leaflet module. I bind to its click event as follows

map.on('click', (event: LeafletMouseEvent) => {
  this.buildingDataService.fetchBuildingData(event.latlng);
});

The callback is, I now realise, fired outside Angular's zone. Angular therefore fails to detect the change to this.building. The solution is to bring the callback in Angular's zone through NgZone as

map.on('click', (event: LeafletMouseEvent) => {
  ngZone.run(() => {
    this.buildingDataService.fetchBuildingData(event.latlng);
  });
});

This solves the problem and every proposed solution works like a charm.

A big thank you for the quick and useful responses. They were instrumental in helping me narrow down the problem!

To be fair: this issue is mentioned in ngx-leaflet's documentation [1]. I failed to understand the implications right away as I'm still learning Angular and there is a lot to take in.

[1] https://github.com/Asymmetrik/ngx-leaflet#a-note-about-change-detection

Simeon Nedkov
  • 1,097
  • 7
  • 11
  • I was unsure what to do as the other answers directly address the question about multicasting whereas my solution fixes something completely unrelated. I'd hate someone to Google for multicasting and end up on a page describing how to fix something with Leaflet. Still unsure what to, so please advise. – Simeon Nedkov Apr 30 '18 at 10:02
  • Well, I think that makes sense too. I guess you can leave this as is. – sabithpocker Apr 30 '18 at 10:12
0

EDIT:

As your change detection seems to struggle with whatsoever detail in your object, I have another suggestion. And as I also prefer the BehaviorSubject over a sipmple Subject I adjusted even this part of code.

1st define a wrapper object. The interesting part is, that we add a second value which definitely has to be unique compared to any other wrapper of the same kind you produce.

I usually put those classes in a models.ts which I then import wherever I use this model.

export class Wrapper {
   constructor(
       public object: Object,
       public uniqueToken: number
   ){}
}

2nd in your service use this wrapper as follows.

import { Observable } from 'rxjs/Observable'; 
import { BehaviorSubject } from 'rxjs/BehaviorSubject'; 
import { Wrapper } from './models.ts';

@Injectable()
export class BuildingDataService {
   private response: BehaviorSubject<Wrapper> = new BehaviorSubject<Wrapper>(new Wrapper(null, 0));

   constructor (private http: HttpClient) { }

   public getResponse(): Observable<Wrapper> {
        return this.response.asObservable();
   }

   fetchBuildingData(location) {
      ...
      this.http.post(url, location, httpOptions).subscribe(resp => {

         // generate and fill the wrapper
         token: number = (new Date()).getTime();
         wrapper: Wrapper = new Wrapper(resp, token);

         // provide the wrapper
         this.response.next(wrapper);
      });
   }

Import the model.ts in all subscribing classes in order to be able to handle it properly and easily. Let your Subscribers subscribe to the getResponse()-method.

This must work. Using this wrapper + unique token technique I always solved such change detection problems eventually.

  • Thanks for the quick reply! The problem, unfortunately, persists. – Simeon Nedkov Apr 25 '18 at 21:31
  • Hi, I understand. I updated my post. I hope this eventually works for you. –  Apr 26 '18 at 05:00
  • Thanks for this creative solution! Unfortunately it does not work either. I get the `BehaviourSubject`'s initial value but nothing afterwards _unless_ I wrap it in `NgZone.run()`. Seeing that your solution is very similar to @sabithpocker's I'm starting to think there is something wrong with my Angular installation (5.2.10). Alternatively, Angular does not do change detection in callbacks: I tried calling `next()` in the service every second through `setInterval` but it also only works by manually triggering a change detection. – Simeon Nedkov Apr 26 '18 at 06:48
  • I found the issue: I'm triggering `fetchBuildingData` from a callback outside of Angular's zone. See my solution. Thanks a million for the help! By providing a number of different solutions you helped me verify that the Subject/Observable part is correct. – Simeon Nedkov Apr 26 '18 at 08:43
0

The code of sabith is wrong (but the idea is the correct)

  //declare a source as Subject
  private responseSource = new Subject<any>(); //<--change object by any

  public response = this.responseSource.asObservable() //<--use"this"

  constructor (private http: HttpClient) { }

  fetchBuildingData(location) {
    this.http.post(url, location, httpOptions).subscribe(resp => {
      this.responseSource.next(resp);
    });
  }

Must be work

Another idea is simply using a getter in your component

//In your service you make a variable "data"
data:any
fetchBuildingData(location) {
    this.http.post(url, location, httpOptions).subscribe(resp => {
      this.data=resp
    });
  }

//in yours components

get data()
{
     return BuildingDataService.data
}
Eliseo
  • 50,109
  • 4
  • 29
  • 67
  • Yes, the getter works beautifully. I wasn't aware tha they are _bound_ to variables and automatically receive new values. – Simeon Nedkov Apr 26 '18 at 09:02
  • No it isn't exactly this way. Angular is not magic, it's must take acount the changes in the application (a click, a change in one input...). When a change happens, it look for all the "things" that can change and show the new values. Using a getter "force" to Angular to look for the new value. – Eliseo Apr 26 '18 at 13:50
  • Thanks for the clarification. How does the "forcing" work? Is it something that's done in Angular or is it a property of JavaScript itself? – Simeon Nedkov Apr 26 '18 at 14:08
  • I don't know, but if you write console.log("***") into a getter you can see that is called several times. (perhaps "forcing" is not the correct word, sorry) – Eliseo Apr 26 '18 at 15:21