angulartypescriptrxjsasync-pipeangular-control-flow

How to Prevent Child Component Destruction When Re-fetching Data with Angular Observables


I have an Angular app that makes multiple HTTP API calls. I combine the results of these APIs into a single stream of data, which is then passed as inputs to child components. In the template, I use the async pipe to subscribe to the stream and only display the child components when the data is available.

Here’s the code I’m working with:

Component Code:

ngOnInit(): void {
    this.getData();
}

getData() {
    const apiDataOne$ = this.http.get<One[]>(URLOne);
    const apiDataTwo$ = this.http.get<Two[]>(URLTwo);

    this.dataStream$ = forkJoin([apiDataOne$, apiDataTwo$]).pipe(
        map(([result1, result2]) => ({ result1, result2 }))
    );
}

updateData() {
    this.getData();
}

HTML Template:

@if(dataStream$ | async as dataStream){
    <child-component [input1]="dataStream.result1" [input2]="dataStream.result2">
    </child-component>
}

Issue:

I want to trigger the getData() method dynamically, such as when a user clicks a button. However, when I call getData() again, it resets the dataStream$ observable. This causes the async pipe to emit null or undefined for a short time, which results in the child component being destroyed and recreated.

I want to prevent the child component from being destroyed and recreated, even when the data is re-fetched.

Questions:


Solution

  • We have to make sure the root observable does not complete or get reset. We can use a BehaviorSubject to be used to trigger reloads when the next method is called. The other thing about BehaviorSubject is that it triggers the stream during initial subscription.

    @Component({
      selector: 'app-root',
      standalone: true,
      imports: [Child, CommonModule],
      template: `
        @if(dataStream$ | async; as dataStream){
            <app-child [input1]="dataStream.result1" [input2]="dataStream.result2">
            </app-child>
            <button (click)="updateData()"> update</button>
        }
      `,
    })
    export class App {
      private loadTrigger: BehaviorSubject<string> = new BehaviorSubject<string>('');
      dataStream$: Observable<ApiResonses> = this.loadTrigger.pipe(
        switchMap(() =>
          forkJoin<{
            result1: Observable<Array<any>>;
            result2: Observable<Array<any>>;
          }>({
            result1: of([{ test: Math.random() }]),
            result2: of([{ test: Math.random() }]),
          })
        )
      );
      ngOnInit(): void {}
    
      updateData() {
        this.loadTrigger.next(''); // <- triggers refresh
      }
    }
    

    So the button click calls the updateData method which calls the BehaviorSubject's next method, which triggers the other API to reevaluate. To switch from the BehaviorSubject observable to the forkJoin we can use switchMap.

    The fork join has a convenient object syntax as shown in the example, so there is no need for the map operation.

    Full Code:

    import { Component, input } from '@angular/core';
    import { bootstrapApplication } from '@angular/platform-browser';
    import { BehaviorSubject, forkJoin, of, switchMap, Observable } from 'rxjs';
    import { CommonModule, JsonPipe } from '@angular/common';
    
    export interface ApiResonses {
      result1: any[];
      result2: any[];
    }
    
    @Component({
      selector: 'app-child',
      standalone: true,
      imports: [JsonPipe],
      template: `
            {{input1() | json}}
            <br/>
            <br/>
            {{input2() | json}}
          `,
    })
    export class Child {
      input1: any = input();
      input2: any = input();
    
      ngOnDestroy() {
        alert('destroyed');
      }
    }
    
    @Component({
      selector: 'app-root',
      standalone: true,
      imports: [Child, CommonModule],
      template: `
                @if(dataStream$ | async; as dataStream){
                    <app-child [input1]="dataStream.result1" [input2]="dataStream.result2">
                    </app-child>
                    <button (click)="updateData()"> update</button>
                }
              `,
    })
    export class App {
      private loadTrigger: BehaviorSubject<string> = new BehaviorSubject<string>(
        ''
      );
      dataStream$: Observable<ApiResonses> = this.loadTrigger.pipe(
        switchMap(() =>
          forkJoin<{
            result1: Observable<Array<any>>;
            result2: Observable<Array<any>>;
          }>({
            result1: of([{ test: Math.random() }]),
            result2: of([{ test: Math.random() }]),
          })
        )
      );
      ngOnInit(): void {}
    
      updateData() {
        this.loadTrigger.next(''); // <- triggers refresh
      }
    }
    
    bootstrapApplication(App);
    

    Stackblitz Demo