Render dynamic components the simple way in Angular – ngComponentOutlet

Connie Leung - Apr 13 '23 - - Dev Community

Introduction

In Angular, there are a few ways to render templates and components dynamically. There is ngTemplateOutlet that can render different instances of ng-template conditionally. When we use components, we can apply ngComponentOutlet to render the dynamic components the simple way or ViewContainerRef the complex way.

In this blog post, I created a new component, PokemonTabComponent, that is consisted of hyperlinks and a ng-container element. When clicking a link, the component renders PokemonStatsComponent, PokemonAbilitiesComponent or both dynamically. NgComponentOutlet helps render the dynamic components the simple way 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: [
    PokemonStatsComponent, PokemonAbilitiesComponent
  ],
  template: `
    <div style="padding: 0.5rem;">
      <ul>
        <li><a href="#" #selection data-type="ALL">All</a></li>
        <li><a href="#" #selection data-type="STATISTICS">Stats</a></li>
        <li><a href="#" #selection data-type="ABILITIES">Abilities</a></li>
      </ul>
    </div>
    <app-pokemon-stats [pokemon]="pokemon"></app-pokemon-stats>
    <app-pokemon-abilities [pokemon]="pokemon"></app-pokemon-abilities>
  `,
  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 links. However, the behaviour would change when I added new codes and and ngComponentOutlet in the inline template to render the dynamic components.

Image description

Compose RxJS code to map mouse click to components



// pokemon-tab.enum.ts

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


Enter fullscreen mode Exit fullscreen mode

First, I defined enum to represent different mouse clicks.



// pokemon-tab.component.ts

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


Enter fullscreen mode Exit fullscreen mode

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



@ViewChildren('selection', { read: ElementRef })
selections: QueryList<ElementRef<HTMLLinkElement>>;


Enter fullscreen mode Exit fullscreen mode

Next, I used the ViewChildren() decorator to query the hyperlinks and the building blocks are in place to construct RxJS code in ngAfterViewInit.



// pokemon-tab.component.ts

export class PokemonTabComponent implements AfterViewInit, OnChanges {
  ...
} 


Enter fullscreen mode Exit fullscreen mode


// pokemon-tab.component.ts

components$!: Observable<DynamicComponentArray>;

ngAfterViewInit(): void {
    const clicked$ = this.selections.map(({ nativeElement }) => fromEvent(nativeElement, 'click')
        .pipe(
          map(() => POKEMON_TAB[(nativeElement.dataset['type'] || 'ALL') as keyof typeof POKEMON_TAB]),
          map((value) => this.componentMap[value]),
        )
    );

    // merge observables to emit enum value and look up Component types
    this.components$ = merge(...clicked$)
      .pipe(startWith(this.componentMap[POKEMON_TAB.ALL]));
}


Enter fullscreen mode Exit fullscreen mode
  • ({ nativeElement }) => fromEvent(nativeElement, 'click') - create Observable that emits value when button is clicked
  • map(() => POKEMON_TAB[(nativeElement.dataset[‘type’] || ‘ALL’) as keyof typeof POKEMON_TAB]) – convert the value of type data attribute to POKEMON_TAB enum member
  • map((value) => this.componentMap[value]) – use POKEMON_TAB enum member to look up component list to render dynamically
  • Assign the component list to clicked$ Observable


this.components$ = merge(...clicked$)
    .pipe(startWith(this.componentMap[POKEMON_TAB.ALL]));


Enter fullscreen mode Exit fullscreen mode
  • merge(…clicked$) – merge the Observables to emit the component list
  • startWith(this.componentMap[POKEMON_TAB.ALL]) – both PokemonStatsComponent and PokemonAbilitiesComponent are rendered initially

Apply ngComponentOutlet to PokemonTabComponent

ngComponentOutlet directive has 3 syntaxes and I will use the syntax that expects component and injector. It is because I require to inject Pokemon object to PokemonStatsComponent and PokemonAbilitiesComponent respectively



<ng-container *ngComponentOutlet="componentTypeExpression;
              injector: injectorExpression;
              content: contentNodesExpression;">
</ng-container>


Enter fullscreen mode Exit fullscreen mode

In the inline template, I replaced <app-pokemon-stats> and <app-pokemon-abilities> with <ng-container> as the host of the dynamic components.



<ng-container *ngFor="let component of components$ | async">
     <ng-container *ngComponentOutlet="component; injector: myInjector"></ng-container>
</ng-container>


Enter fullscreen mode Exit fullscreen mode

In the imports array, import NgFor, AsyncPipe and NgComponentOutlet.



imports: [PokemonStatsComponent, PokemonAbilitiesComponent, NgFor, AsyncPipe, NgComponentOutlet],


Enter fullscreen mode Exit fullscreen mode

In the inline template, myInjector has not declared and I will complete the implementation in ngAfterViewInit and ngOnChange.

Let's define a new injection token for the Pokemon object



// pokemon.constant.ts

import { InjectionToken } from "@angular/core";
import { FlattenPokemon } from "../interfaces/pokemon.interface";

export const POKEMON_TOKEN = new InjectionToken<FlattenPokemon>('pokemon_token');


Enter fullscreen mode Exit fullscreen mode

createPokemonInjectorFn is a high-order function that returns a function to create an injector to inject an arbitrary Pokemon object.



// pokemon.injector.ts

export const createPokemonInjectorFn = () => {
  const injector = inject(Injector);

  return (pokemon: FlattenPokemon) =>
    Injector.create({
      providers: [{ provide: POKEMON_TOKEN, useValue:pokemon }],
      parent: injector
    });
}


Enter fullscreen mode Exit fullscreen mode

In PokemonTabComponent, I imported POKEMON_TOKEN and createPokemonInjectorFn to assign new injector to myInjector in ngAfterViewInit and ngOnChanges.



// pokemon-tab.component.ts

injector = inject(Injector);
myInjector!: Injector;
createPokemonInjector = createPokemonInjectorFn();
markForCheck = inject(ChangeDetectorRef).markForCheck;

ngAfterViewInit(): void {
    this.myInjector = this.createPokemonInjector(this.pokemon);
    this.markForCheck();
    ...
}

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


Enter fullscreen mode Exit fullscreen mode

At this point, the application would not work because PokemonStatsComponent and PokemonAbilitiesComponent had not updated to inject the Pokemon object. This would be the final step to have a working example.

Inject Pokemon to PokemonStatsComponent and PokemonAbilitiesComponent



// pokemon-stats.component.ts

export class PokemonStatsComponent {
  pokemon = inject(POKEMON_TOKEN);
}
// pokemon-abilities.component.ts

export class PokemonAbilitiesComponent {
  pokemon = inject(POKEMON_TOKEN);
}


Enter fullscreen mode Exit fullscreen mode

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:

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