Show dynamic components in Angular using ViewContainerRef

Introduction

In Angular, ngComponentOutlet is the easy way to show components dynamically. The other option is to show dynamic components using ViewContainerRef class. ViewContainerRef class has createComponent method that creates component and puts it to a container. It is a powerful technique to add components at runtime; they are not loaded in main bundle to keep the bundle size small. When there are many components that show conditionally, ViewContainerRef solution is better than multiple ng-if expressions that quickly make the inline template messy.

In this blog post, I created a new component, PokemonTabComponent, that has radio buttons and a ng-container element. When clicking a radio button, the component shows PokemonStatsComponent, PokemonAbilitiesComponent or both dynamically. Showing dynamic components using ViewContainerRef is more complex than ngComponentOutlet and we will see how it works for the rest of the post.

The skeleton code of Pokemon Tab component

// pokemon-tab.component.ts

@Component({
  selector: 'app-pokemon-tab',
  standalone: true,
  template: `
    <div style="padding: 0.5rem;" class="container">
      <div>
        <div>
          <input type="radio" id="all" name="selection" value="all" checked">
          <label for="all">All</label>
        </div>
        <div>
          <input type="radio" id="stats" name="selection" value="stats">
          <label for="stats">Stats</label>
        </div>
        <div>
          <input type="radio" id="abilities" name="selection" value="abilities">
          <label for="abilities">Abilities</label>
        </div>
      </div>
      <ng-container #vcr></ng-container>
    </div>
  `,
  styles: [`...omitted due to brevity...`],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PokemonTabComponent {
  @Input()
  pokemon: FlattenPokemon;
}

In PokemonTabComponent standalone component, nothing happened when clicking the radio buttons. However, the behavior would change when I started to add new codes to render dynamic components using ViewContainerRef. The end result is to display the components in the ng-container named vcr.

Skeleton code of the example

Map radio button selection to component types

// pokemon-tab.component.ts

private async getComponenTypes() {
    const { PokemonStatsComponent } = await import('../pokemon-stats/pokemon-stats.component');
    const { PokemonAbilitiesComponent } = await import('../pokemon-abilities/pokemon-abilities.component');

    if (this.selection === 'ALL') {
      return [PokemonStatsComponent, PokemonAbilitiesComponent];
    } else if (this.selection === 'STATISTICS')  {
      return [PokemonStatsComponent];
    }

    return [PokemonAbilitiesComponent];    
 }

First, I defined a method, getComponenTypes, to lazy load standalone components. Then, I examine the value of this.selection to return component type list.

@ViewChild('vcr', { static: true, read: ViewContainerRef })
vcr!: ViewContainerRef;

Next, I used the ViewChild() decorator to obtain the reference to ng-container element and invoke ViewContainerRef class to append dynamic components to the container.

Add click event handler to radio buttons

When I click any radio button, I wish to look up the component types in componentTypeMap, iterate the list to create new component references and append them to vcr. In inline template, I add click event handler to the radio buttons that execute renderDynamicComponents method to render dynamic components.

// pokemon-tab.component.ts

template:`  
...
<div>
   <input type="radio" id="all" name="selection" value="all"
   checked (click)="selection = 'ALL'; renderDynamicComponents();">
   <label for="all">All</label>
</div>
<div>
    <input type="radio" id="stats" name="selection" value="stats"
       (click)="selection = 'STATISTICS'; renderDynamicComponents();">
    <label for="stats">Stats</label>
</div>
<div>
    <input type="radio" id="abilities" name="selection" value="abilities"
       (click)="selection = 'ABILITIES'; renderDynamicComponents();">
    <label for="abilities">Abilities</label>
</div>
...
`

selection: 'ALL' | 'STATISTICS' | 'ABILITIES' = 'ALL';
componentRefs: ComponentRef<PokemonStatsComponent | PokemonAbilitiesComponent>[] = [];
cdr = inject(ChangeDetectorRef);

async renderDynamicComponents(currentPokemon?: FlattenPokemon) {
    const componentTypes = await this.getComponenTypes();

    // clear dynamic components shown in the container previously    
    this.vcr.clear();
    for (const componentType of componentTypes) {
      const newComponentRef = this.vcr.createComponent(componentType);
      newComponentRef.instance.pokemon = currentPokemon ? currentPokemon : this.pokemon;
      // store component refs created
      this.componentRefs.push(newComponentRef);
      // run change detection in the component and child components
      this.cdr.detectChanges();
    }
}

this.selection keeps track of the currently selected radio button and the value determines the component/components that get(s) rendered in the container.

this.vcr.clear(); removes all components from the container and inserts new components dynamically

const newComponentRef = this.vcr.createComponent(componentType); instantiates and appends the new component to the container, and returns a ComponentRef.

newComponentRef.instance.pokemon = currentPokemon ? currentPokemon : this.pokemon;

PokemonStatsComponent and PokemonAbilitiesComponent expect a pokemon input; therefore, I assign a Pokemon object to newComponentRef.instance.pokemon where newComponentRef.instance is a component instance.

this.componentRefs.push(newComponentRef); stores all the ComponentRef instances and later I destroy them in ngOnDestroy to avoid memory leak.

this.cdr.detectChanges(); triggers change detection to update the component and its child components.

Destroy dynamic components in OnDestroy lifecycle hook

Implement OnDestroy interface by providing a concrete implementation of ngOnDestroy.

export class PokemonTabComponent implements OnDestroy {
   ...other logic...

   ngOnDestroy(): void {
    // release component refs to avoid memory leak
    for (const componentRef of this.componentRefs) {
      if (componentRef) {
        componentRef.destroy();
      }
    }
  }
}

The method iterates componentRefs array and frees the memory of each ComponentRef to avoid memory leak.

Render dynamic components in ngOnInit

When the application is initially loaded, the page is blank because it has not called renderDynamicComponents yet. It is easy to solve by implementing OnInit interface and calling the method in the body of ngOnInit.

export class PokemonTabComponent implements OnDestroy, OnInit {
   ...
   ngOnInit(): void {
     this.renderDynamicComponents();
   }
}

When Angular runs ngOnInit, the initial value of this.selectionis ‘ALL’ andrenderDynamicComponents` displays both components at first.

Now, the initial load renders both components but I have another problem. Button clicks and form input change do not update the Pokemon input of the dynamic components. It can be solved by implementing OnChanges interface and calling renderDynamicComponents again in ngOnChanges.

Re-Render dynamic components in ngOnChanges

ngOnChanges

changes['pokemon'].currentValue is the new Pokemon input. this.renderDynamicComponents(changes['pokemon'].currentValue) passes the new Pokemon to assign to the new components and to display them in the container dynamically.

The following Stackblitz repo shows the final results:

I hope you like the content and continue to follow my colection experience in Angular and other technologies.

Resources:

No comments:

Post a Comment