Reputation: 1138
I'm not sure what's the preferred Angular way to solve the problem where a template displays information based on two different sources.
For example, I have a contact list stored in a database (illustrated here as a hard coded array in the component). Each contact has a presence information that is coming from network events and NOT stored in the DB. That presence information is collected and stored in PresenceService
.
@Injectable()
export class PresenceService {
private readonly onlineUsers: { [s: string]: boolean; }; // {uid: online}
constructor() {
// demo data, normally comes from network events
this.onlineUsers = { 1: true, 2: false };
}
isOnline(uid: string) {
return this.onlineUsers[uid];
}
}
I want to display the contact list with the presence information:
@Component({
selector: 'my-app',
template: `
<h3> Your contacts </h3>
<p *ngFor="let c of contacts">
{{c.name}} is {{presence.isOnline(c.uid) ? 'online' : 'offline'}}
</p>
`
})
export class AppComponent {
contacts = [{
uid: 1,
name: 'John'
}, {
uid: 2,
name: 'Melinda'
}]
constructor(public presence: PresenceService) {}
}
I see three ways of solving this:
contacts
object a temporary presence property that isn't stored in the DB.What's the Angular best practice for such cases ?
Upvotes: 2
Views: 457
Reputation: 6441
Chances are that in this scenario, both your sources can be exposed as Observables:
@Injectable()
export class PresenceService {
public onlineUsers$: Observable<{}>;
}
@Injectable()
export class ContactService {
public contacts$: Observable<Contact[]>;
}
In your component, you would glue those 2 Observables together with rxjs:
@Component({
selector: 'my-app',
template: `
<h3> Your contacts </h3>
<ng-container *ngIf="contactsWithPresenceInfo$|async; let contacts">
<p *ngFor="let c of contacts">
{{c.name}} is {{c.isOnline ? 'online' : 'offline'}}
</p>
</ng-container>
`
})
export class AppComponent implements OnDestroy, OnInit {
private destroy$ = new Subject();
public contactsWithPresenceInfo$: Observable<any>;
constructor(public presenceService: PresenceService, public contactService: ContactService) {}
ngOnInit(): void {
// Attention: import combineLatest from 'rxjs', not from 'rxjs/operators'
// When combineLatest is imported from 'rxjs/operators', it can be used as an operator (passed in the pipe function)
// The one imported from 'rxjs' is the "creation" variant
this.contactsWithPresenceInfo$ = combineLatest(
this.presenceService.onlineUsers$,
this.contactService.contacts$
).pipe(
map(([presenceInfo, contacts]) => mergePresenceInfoOntoContacts(presenceInfo, contacts)),
takeUntil(this.destroy$)
);
}
mergePresenceInfoOntoContacts(presenceInfo, contacts) {
// loop over your contacts, apply the presence info
// and return them in this format:
// [{ name: '', isOnline: boolean }]
return [];
}
ngOnDestroy(): void {
this.destroy$.next();
}
}
Keep in mind: combineLatest will only return data if every Observable has emitted a value at least once! That means if your contacts are loaded from database, but you have not yet received any presence info, contactsWithPresenceInfo$ will return nothing. This can be easily fixed by using startWith:
this.contactsWithPresenceInfo$ = combineLatest(
this.presenceService.onlineUsers$.pipe(startWith({})),
this.contactService.contacts$
).pipe(
map(([presenceInfo, contacts]) => mergePresenceInfoOntoContacts()),
takeUntil(this.destroy$)
);
The nice thing about this approach is that for every new response of either the ContactService or the PresenceService, a new object will be constructed (immutability!). You will be able to set ChangeDetection.OnPush on your component, and gain some performance because much less change detections will be triggered. Immutability, RXJS and the OnPush strategy work well together ...
I have included a takeUntil operator and passed in the destroy$ subject because it is a habit: this works as a kind of auto-unsubscribe for all defined rxjs statements. In this case it is not really necessary because the async pipe would manage the unsubscribe for you.
Upvotes: 1
Reputation: 6976
I think the direct answer to your question, keeping online presence vs contact information separate all the way to template vs joining them up-stream in a service, is personal preference. Having said that, here are some of my thoughts:
Change the PresenceService
to something of the following:
@Injectable()
export class PresenceService {
private readonly onlineUsers = new BehaviorSubject<string[]>([]);
constructor() {
// This line is written with the assumption that your websocket is wrapped
// in an Observable but by no means does it have to be.
myWebsocket.subscribe(onlineUsers => this.onlineUsers.next(this.onlineUsers))
}
isOnline(uid: string): Observable<boolean> {
return this.onlineUsers.pipe(map(users => users.includes(uid)));
}
}
Discussion Points:
onlineUsers
from {[s: string]: boolean}
to a simple string array. My reason is that if the array only contains the list of users that are online, then it doesn't need to also store the list of users that are not online.isOnline
method is an observable so that if a user comes online while your component displaying the list of users is already rendered, then that component will always show the correct and up-to-date online-presence information for all users.It is usually advised not to expose the services to the templates. That means that the component class should provide a method that wraps the required service methods:
isOnline(uid: string): Observable<boolean> {
return this.presenceService.isOnline(uid);
}
The reason for that is twofold. (1.) The template should not know where the data is coming from, (2.) it usually makes the template simpler/less cluttered.
With the changes discussed above, your template now looks as follows:
<p *ngFor="let c of contacts">
{{ c.name }} is {{ (isOnline(c.uid) | async) ? 'online' : 'offline' }}
</p>
Having walked through the areas discussed above, I personally would keep the information of contacts and their online presence separate as long as the contacts are retrieved only one time when the component is rendered (where the source may be an HTTP request to the backend) but their online presence may change over the life of the component (updated through a websocket). My reasoning is that this way the *ngFor
in the template can loop over the contacts once and not have to change again even if the contacts' online presence changes.
Upvotes: 2
Reputation: 14159
I have to make a few assumptions about your setup, but I believe this is what you are looking for:
In your PresenceService
, you have some kind of network event happening. Convert this into an Observable, e.g. using the fromEvent
creation function. For simplicity, I'm using Angular HTTP to demonstrate asynchronous events.
Adapt your isOnline
function to return Observable, like so:
isOnline(uid: string): Observable<boolean> {
return this.http.get("/presence/" + uid);
}
If you are calling this function more than once for each contact - which you probably are when using *ngFor
- then I suggest piping the result to a shareReplay(1)
operator. It will "cache" the response.
isOnline(uid: string): Observable<boolean> {
return this.http.get("/presence/" + uid).pipe(shareReplay(1));
}
Now, in your Angular template, make use of the async
pipe. It subscribes and later unsubscribes to a given Observable. You can use it to initiate your network request and receive updates. Angular will handle updating the value on notification for you.
Your template could look like this:
<h3> Your contacts </h3>
<p *ngFor="let c of contacts">
{{c.name}} is {{(presence.isOnline(c.uid) | async) ? 'online' : 'offline'}}
</p>
Upvotes: 1