Customize component using ngComponentOutlet and signals in Angular

Connie Leung - Jul 1 '23 - - Dev Community

Introduction

In this blog post, I want to describe how to do UI customization using ngComponentOutlet and Angular signals. Originally, I had a PokemonTabComponent that renders dynamic components using ngComponentOutlet and RxJS. I refactored the component to use signals and the code is surprisingly short, easy to understand and maintain. Moreover, I apply "signals in a service" pattern to organize signals in a service, dynamic components can inject the service to access the signals and invoke signal functions in inline template to render content.

Create a service using "Signals in a Service"

// pokemon.service.ts

// ...omitted import statements ...

const initialValue: DisplayPokemon = {
  id: -1,
  name: '',
  height: -1,
  weight: -1,
  backShiny: '',
  frontShiny: '',
  abilities: [],
  stats: [],
};

const pokemonTransformer = (pokemon: Pokemon): DisplayPokemon => {
  const { id, name, height, weight, sprites, abilities: a, stats: statistics } = pokemon;

  const abilities: Ability[] = a.map(({ ability, is_hidden }) => ({
    name: ability.name,
    isHidden: is_hidden
  }));

  const stats: Statistics[] = statistics.map(({ stat, effort, base_stat }) => ({
    name: stat.name,
    effort,
    baseStat: base_stat,
  }));

  return {
    id,
    name,
    height,
    weight,
    backShiny: sprites.back_shiny,
    frontShiny: sprites.front_shiny,
    abilities,
    stats,
  }
}

@Injectable({
  providedIn: 'root'
})
export class PokemonService {
  private readonly pokemonIdSub = new BehaviorSubject(1);
  private readonly httpClient = inject(HttpClient);

  private readonly pokemon$ =  this.pokemonIdSub
    .pipe(
      switchMap((id) => this.httpClient.get<Pokemon>(`https://pokeapi.co/api/v2/pokemon/${id}`)),
      map((pokemon) => pokemonTransformer(pokemon))
    );
  pokemon = toSignal(this.pokemon$, { initialValue });

  personalData = computed(() => {
    const { id, name, height, weight } = this.pokemon();
    return [
      { text: 'Id: ', value: id },
      { text: 'Name: ', value: name },
      { text: 'Height: ', value: height },
      { text: 'Weight: ', value: weight },
    ];
  });

  updatePokemonId(input: PokemonDelta | number) {
    if (typeof input === 'number') {
      this.pokemonIdSub.next(input); 
    } else {
      const potentialId = this.pokemonIdSub.getValue() + input.delta;
      const newId = Math.min(input.max, Math.max(input.min, potentialId));
      this.pokemonIdSub.next(newId); 
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

First, I define PokemonService to create pokemon signal and use computed keyword to compute personalData signal based on it. When pokemonIdSub BehaviorSubject emits an id in updatePokemonId method, a HTTP request is made to retrieve the specified Pokemon from the API. However, HttpClient returns an Observable; therefore, I convert it to signal using toSignal with an initial value.

After PokemonService defines pokemon and personalData signals, components can inject the service to access the signals and call the signal functions within their inline templates.

The skeleton code of Pokemon Tab component

// pokemon-tab.component.ts

import { NgComponentOutlet, NgFor } from '@angular/common';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { PokemonAbilitiesComponent } from '../pokemon-abilities/pokemon-abilities.component';
import { PokemonStatsComponent } from '../pokemon-stats/pokemon-stats.component';

@Component({
  selector: 'app-pokemon-tab',
  standalone: true,
  imports: [
    PokemonAbilitiesComponent,
    PokemonStatsComponent,
    NgFor,
  ],
  template: `
    <div style="padding: 0.5rem;">
      <div>
        <div>
          <input id="all" name="type" type="radio" (click)="selectComponents('all')" checked />
          <label for="all">All</label>
        </div>
        <div>
          <input id="stats" name="type" type="radio" (click)="selectComponents('statistics')" />
          <label for="stats">Stats</label>
        </div>
        <div>
          <input id="ability" name="type" type="radio" (click)="selectComponents('abilities')" />
          <label for="ability">Abilities</label>
        </div>
      </div>
    </div>
    <ng-container *ngFor="let component of dynamicComponents">
      <ng-container></ng-container>
    </ng-container>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PokemonTabComponent {
  dynamicComponents = [];

  selectComponents(type: string) {}
}
Enter fullscreen mode Exit fullscreen mode

In PokemonTabComponent standalone component, nothing happened when clicking the radio button. However, the behaviour would change when I add new logic and ngComponentOutlet to the inline template to render the dynamic components.

Determine component types based on radio selection

// pokemon-tab.component.ts

componentMap: Record<string, any> = {
    'statistics': [PokemonStatsComponent],
    'abilities': [PokemonAbilitiesComponent],
    'all': [PokemonStatsComponent, PokemonAbilitiesComponent],
}
Enter fullscreen mode Exit fullscreen mode

First, I define an object map to map type to the component lists.

dynamicComponents = this.componentMap['all'];

selectComponents(type: string) {
    const components = this.componentMap[type];
    if (components !== this.dynamicComponents) {
      this.dynamicComponents = components;
    }
}
Enter fullscreen mode Exit fullscreen mode

By default, dynamicComponents should render all component types, PokemonStatsComponent and PokemonAbilitiesComponent. selectComponents accepts a type argument, look up component list in this.componentMap and assigns the list back to this.dynamicComponents.

<ng-container *ngFor="let component of dynamicComponents">
    <ng-container></ng-container>
</ng-container>
Enter fullscreen mode Exit fullscreen mode

Next, ngFor iterates dynamicComponents and feeds componentType to ngComponentOutlet to render the component dynamically.

Apply ngComponentOutlet to PokemonTabComponent

ngComponentOutlet directive has 3 syntaxes and I use the syntax that expects component type this time. It is because I inject PokemonService in PokemonStatsComponent and PokemonAbilitiesComponent to obtain pokemon signal respectively.

<ng-container *ngComponentOutlet="componentTypeExpression;"></ng-container>
Enter fullscreen mode Exit fullscreen mode

In the inline template, I replaced <ng-container></ng-container> with

<ng-container *ngFor="let componentType of dynamicComponents">
   <ng-container *ngComponentOutlet="componentType"></ng-container>
</ng-container>
Enter fullscreen mode Exit fullscreen mode

In the imports array, import NgComponentOutlet.

imports: [PokemonStatsComponent, PokemonAbilitiesComponent, NgFor, NgComponentOutlet],
Enter fullscreen mode Exit fullscreen mode

At this point, the application does not work because PokemonStatsComponent and PokemonAbilitiesComponent have not updated to inject PokemonService to access pokemon signal. This is the final step to have a working example.

Inject PokemonService to PokemonStatsComponent and PokemonAbilitiesComponent

// pokemon-stats.component.ts

export class PokemonStatsComponent {
  pokemon = inject(PokemonService).pokemon;
}
Enter fullscreen mode Exit fullscreen mode

Modify inline template to obtain statistics from pokemon signal

<div style="padding: 0.5rem;">
   <p>Stats</p>
   <ng-container *ngTemplateOutlet="content; context: { $implicit: pokemon().stats }"></ng-container>
</div>
<ng-template #content let-stats>
   <div *ngFor="let stat of stats" class="stats-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.baseStat }}</span>
     </label>
      <label>
         <span style="font-weight: bold; color: #aaa">Effort: </span>
         <span>{{ stat.effort }}</span>
      </label>
   </div>
</ng-template>
Enter fullscreen mode Exit fullscreen mode
// pokemon-abilities.component.ts

export class PokemonAbilitiesComponent {
  pokemon = inject(PokemonService).pokemon;
}
Enter fullscreen mode Exit fullscreen mode

Modify inline template to obtain abilities from pokemon signal

<div style="padding: 0.5rem;">
    <p>Abilities</p>
    <ng-container *ngTemplateOutlet="content; context: { $implicit: pokemon().abilities }"></ng-container>
</div>
<ng-template #content let-abilities>
    <div *ngFor="let ability of abilities" class="abilities-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.isHidden ? 'Yes' : 'No' }}</span>
      </label>
    </div>
</ng-template> 
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:

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