Render ngTemplates dynamically using ViewContainerRef in Angular

Connie Leung - May 1 '23 - - Dev Community

Introduction

In Angular, we use ngTemplateOutlet to display ngTemplate when we know which ones and the exact number of them during development time. The other option is to render ngTemplates using ViewContainerRef class. ViewContainerRef class has createEmbeddedView method that instantiates embedded view and inserts it to a container. When there are many templates that render conditionally, ViewContainerRef solution is cleaner than multiple ngIf/ngSwitch expressions that easily clutter inline template.

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

The skeleton code of Pokemon Tab component



// pokemon-tab.component.ts

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

    <ng-template #stats let-pokemon>
      <div>
        <p>Stats</p>
        <div *ngFor="let stat of pokemon.stats" class="flex-container">
          <label>
            <span style="font-weight: bold; color: #aaa">Name: </span>
            <span>{{ stat.name }}</span>
          </label>
          <label>
            <span style="font-weight: bold; color: #aaa">Base Stat: </span>
            <span>{{ stat.base_stat }}</span>
          </label>
          <label>
            <span style="font-weight: bold; color: #aaa">Effort: </span>
            <span>{{ stat.effort }}</span>
          </label>
        </div>
      </div>
    </ng-template>

    <ng-template #abilities let-pokemon>
      <div>
        <p>Abilities</p>
        <div *ngFor="let ability of pokemon.abilities" class="flex-container">
          <label>
            <span style="font-weight: bold; color: #aaa">Name: </span>
            <span>{{ ability.name }}</span>
          </label>
          <label>
            <span style="font-weight: bold; color: #aaa">Is hidden? </span>
            <span>{{ ability.is_hidden ? 'Yes' : 'No' }}</span>
          </label>
          <label>&nbsp;</label>
        </div>
      </div>
    </ng-template>
  `,
  styles: [`...omitted due to brevity...`],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PokemonTabComponent {
  @Input()
  pokemon: FlattenPokemon;
}


Enter fullscreen mode Exit fullscreen mode

In PokemonTabComponent standalone component, nothing happened when clicking the radio buttons. However, the behavior would change when I called ViewContainerRef class to create embedded views from ngTemplates and insert the templates to the container named vcr. The end result is to display templates "stats" and/or "abilities" conditionally without using structure directives such as ngIf and ngSwitch.

Skeleton code

Access embedded ngTemplates in PokemonTabComponent

In order to create embedded views, I need to pass TemplateRef to createEmbeddedView method of ViewContainerRef class. I can specify the template variable of ngTemplate in ViewChild to obtain the TemplateRef.



// pokemon-tab.component.ts

// obtain reference to ng-container element
@ViewChild('vcr', { static: true, read: ViewContainerRef })
vcr!: ViewContainerRef;

// obtain reference ngTemplate named stats
@ViewChild('stats', { static: true, read: TemplateRef })
statsRef!: TemplateRef<any>;

// obtain reference ngTemplate named abilities
@ViewChild('abilities', { static: true, read: TemplateRef })
abilitiesRef!: TemplateRef<any>;


Enter fullscreen mode Exit fullscreen mode

In the above codes, statsRef and abilitiesRef are the TemplateRef instances of stats and abilities ngTemplates respectively.

Add click event handler to radio buttons

When I click any radio button, I wish to look up the TemplateRef instances, create embedded views and append them to vcr. In inline template, I add click event handler to the radio buttons that execute renderDynamicTemplates method to render templates.



// pokemon-tab.component.ts

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

selection: 'ALL' | 'STATISTICS' | 'ABILITIES' = 'ALL';
embeddedViewRefs: EmbeddedViewRef<any>[] = [];

cdr = inject(ChangeDetectorRef);

private getTemplateRefs() {
  if (this.selection === 'ALL') {
      return [this.statsRef, this.abilitiesRef];
  } else if (this.selection === 'STATISTICS') {
      return [this.statsRef];
  }

  return [this.abilitiesRef];
}

renderDyanmicTemplates(currentPokemon?: FlattenPokemon) {
    const templateRefs = this.getTemplateRefs();
    const pokemon = currentPokemon ? currentPokemon : this.pokemon;

    this.vcr.clear();
    for (const templateRef of templateRefs) {
      const embeddedViewRef = this.vcr.createEmbeddedView(templateRef, { $implicit: pokemon });
      this.embeddedViewRefs.push(embeddedViewRef);
      // after appending each embeddedViewRef to conta iner, I trigger change detection cycle
      this.cdr.detectChanges();
    }
}


Enter fullscreen mode Exit fullscreen mode

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

const templateRefs = this.getTemplateRefs(); examines the value of this.selection, constructs and returns TemplateRef[]. When the selection is 'ALL', getTemplateRefs returns both template references. When the selection is 'STATISTICS', getTemplateRefs returns the template reference of stats template in an array. Otherwise, the method returns the template reference of abilities template in an array.

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

const embeddedViewRef = this.vcr.createEmbeddedView(templateRef, { $implicit: pokemon }); instantiates and appends the new embedded view to the container, and returns a EmbeddedViewRef.

{ $implicit: pokemon } is the template context and the template has a local variable named pokemon that references a Pokemon object.

this.embeddedViewRefs.push(embeddedViewRef); stores all the EmbeddedViewRef 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.

This summarizes how to render ngTemplates using createEmbeddedView method of ViewContainerRef Class.

Destroy embedded views in OnDestroy lifecycle hook

Implement OnDestroy interface by providing a concrete implementation of ngOnDestroy.



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

   ngOnDestroy() {
    // destroy embeddedViewRefs to avoid memory leak
    for (const viewRef of this.embeddedViewRefs) {
      if (viewRef) {
        viewRef.destroy();
      }
    }
  }
}


Enter fullscreen mode Exit fullscreen mode

The method iterates embeddedViewRefs array and frees the memory of each EmbeddedViewRef to avoid memory leak.

Render embedded views in ngOnInit

When the application is initially loaded, the page is blank because it has not called renderDynamicTemplates 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.renderDynamicTemplates();
   }
}


Enter fullscreen mode Exit fullscreen mode

When Angular runs ngOnInit , the initial value of this.selection is 'ALL' and renderDynamicTemplates displays both templates at first.

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

Re-render embedded views in ngOnChanges



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

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


Enter fullscreen mode Exit fullscreen mode

changes['pokemon'].currentValue is the new Pokemon input. this.renderDynamicTemplates(changes['pokemon'].currentValue) passes the new Pokemon to template context and the new embedded views display the new value in the container.

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:

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .