Render dynamic components in Angular using viewContainerRef

Reading Time: 4 minutes

Loading

Introduction

In Angular, ngComponentOutlet is the simple way to render components dynamically. The other option is to render dynamic components using ViewContainerRef class. ViewContainerRef class has createComponent method that instantiates component and inserts 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 render conditionally, ViewContainerRef solution is cleaner than multiple ng-if expressions that easily bloat the inline template.

In this blog post, I created a new component, PokemonTabComponent, that is consisted of radio buttons and a ng-container element. When clicking a radio button, the component renders PokemonStatsComponent, PokemonAbilitiesComponent or both dynamically. Rendering dynamic components using ViewContainerRef is more sophisticated than ngComponentOutlet and we will see its usage for the rest of the post.

let's go

The skeleton code of Pokemon Tab component

// pokemon-tab.component.ts

@Component({
  selector: 'app-pokemon-tab',
  standalone: true,
  imports: [PokemonStatsComponent, PokemonAbilitiesComponent],
  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.

Map radio button selection to component types

// pokemon-tab.enum.ts

export enum POKEMON_TAB {
    ALL = 'all',
    STATISTICS = 'statistics',
    ABILITIES = 'abilities'
}

First, I defined enum to represent different radio button selections.

// pokemon-tab.component.ts

componentTypeMap = {
    [POKEMON_TAB.ALL]: [PokemonStatsComponent, PokemonAbilitiesComponent],
    [POKEMON_TAB.STATISTICS]: [PokemonStatsComponent],
    [POKEMON_TAB.ABILITIES]: [PokemonAbilitiesComponent],
  };

Then, I defined an object map to map the enum members to the component type lists.

@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 enumValue = POKEMON_TAB[this.selection as keyof typeof POKEMON_TAB];
    const componentTypes = this.componentTypeMap[enumValue];

    // 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.selection is ‘ALL’ and renderDynamicComponents 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

export class PokemonTabComponent implements OnDestroy, OnInit, OnChanges {
   ...

   ngOnChanges(changes: SimpleChanges): void {
      this.renderDynamicComponents(changes['pokemon'].currentValue);
   }
}

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:

This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular and other technologies.

Resources:

  1. Github Repo: https://github.com/railsstudent/ng-pokemon/tree/main/projects/pokemon-demo-11
  2. Stackblitz: https://stackblitz.com/edit/angular-dstj5e?file=src/pokemon/pokemon-tab/pokemon-tab.component.ts
  3. Youtube: https://www.youtube.com/watch?v=1Torp0Xgv0U
  4. PokeAPI: https://pokeapi.co/