Search code examples
angularrxjsrxjs-observablesasync-pipe

Setting selected item of a select element using async pipes in Angular


Angular experts! I'm trying to understand async pipes in Angular, but I'm stuck in a basic scenario. I have two select elements in the UI, one containing posts and one containing related comments. I want to set a posting (the last one) as the initially selected one for the select element displaying posts, and I want to use the selected item to filter related comments in the second select. This is not working in my code, for which I have created a simplified version in Stackblitz:

https://stackblitz.com/edit/angular-p6ynuy

Can any of you explain to me what I'm doing wrong? This is the relevant code fragment and HTML:

ngOnInit() {
    this.postList$ = this.getPostList();

    // latestPost$ is not is use yet, but maybe it could be used to set the selected post?
    this.latestPost$ = this.postList$ 
      .pipe(
        map(posts => posts[posts.length - 1])
      );

    this.selectedPost$ = combineLatest([
      this.postList$,
      this.postSelectedAction$
    ])
      .pipe(
        map(([posts, selectedPostId]) => posts.find(post => post.id === selectedPostId))
      );

    this.commentList$ = this.selectedPost$
      .pipe(switchMap(
        post => this.getCommentList(post)
      ));
  }


<select [ngModel]="selectedPost$ | async" (change)="onSelected($event.target.value)">
  <option *ngFor="let post of postList$ | async" [ngValue]="post">
    {{post.id}} {{post.title}}
  </option>
</select>
<select>
  <option *ngFor="let comment of commentList$ | async" [ngValue]="comment">
    {{comment.id}} {{comment.postId}} {{comment.name}}
  </option>
</select>

Solution

  • Angular Compares Objects by Reference by Default

    You are almost there. The problem is that your select gets a list of options referencing Posts as part of ngFor. Now to find out which option is currently selected, Angular compares each post object with the current value of selectedPost$ | async.

    The way that's done is, by default, using the === operator. The === operator compares primitives by value, but objects by reference. Example:

    console.log('a' === 'a');
    const obj = {'a': 'b'};
    const obj2 = obj;
    console.log(obj === obj2);
    console.log(obj === {'a': 'b'});

    So in order for post to count as the same post as selectedPost$ | async, they'd have to be the actual same object, not just an object that looks the same.

    You Actually Retrieve Multiple Copies of The Same Post, Not Just one Post

    Now that's not the case: As you're using the async pipe, it can happen that during change detection, posts are reloaded from the API. When you check out your browser's network tab, you can actually see that there are three requests:

    enter image description here

    The response payload of all requests is the same, but as the Post objects are returned three times, they are stored in memory three times. JavaScript has no way of knowing they are actually the same, and the === comparison returns false.

    Solution: Provide Your Own compareWith function

    How can you solve this? You can help Angular compare Post objects properly in your select. You just have to ask yourself the question: How do I know that two Post objects are in fact the same object? The answer, in this case, is: When they have the same ID.

    You can now write your own instruction for Angular to compare objects or your select: Just add a compareWith input to the select:

    <select [ngModel]="selectedPost$ | async" 
    (change)="onSelected($event.target.value)"
    [compareWith]="comparePosts"
    >
    

    Now Angular knows to use a method called comparePosts to compare two posts. Now how can that method look? For example like this:

    comparePosts(p1: Post, p2: Post) {
        return p1.id === p2.id;
    }
    

    Now Angular knows how to properly compare two Post objects and your problem is solved.

    PS: Please make sure to write a better comparePosts method than I did, for example also properly handling undefined and null values.