Customize component using ViewContainerRef and signals in Angular

Connie Leung - Jul 21 '23 - - Dev Community

Introduction

In this blog post, I want to describe how to perform UI customization using ViewContainerRef and Angular signals. Originally, I have a PokemonTabComponent that renders dynamic components using ViewContainerRef and the components are assigned a pokemon input. After rewrite, I refactor these components to inject Pokemon service in order to access the pokemon signal. After obtaining the signal in the component, the inline template can invoke signal function to render data subsequently.

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 { ChangeDetectionStrategy, ChangeDetectorRef, Component, ComponentRef, OnInit, ViewChild, ViewContainerRef, inject } from '@angular/core';

type SelectionType = 'ALL' | 'STATISTICS' | 'ABILITIES';

@Component({
  selector: 'app-pokemon-tab',
  standalone: true,
  template: `
    <div style="padding: 0.5rem;">
      <div>
        <div>
          <input id="all" name="type" type="radio" (click)="renderComponentsBySelection('ALL')" checked />
          <label for="all">All</label>
        </div>
        <div>
          <input id="stats" name="type" type="radio" (click)="renderComponentsBySelection('STATISTICS')" />
          <label for="stats">Stats</label>
        </div>
        <div>
          <input id="ability" name="type" type="radio" (click)="renderComponentsBySelection('ABILITIES')" />
          <label for="ability">Abilities</label>
        </div>
      </div>
    </div>
    <ng-container #vcr></ng-container>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PokemonTabComponent {
  @ViewChild('vcr', { static: true, read: ViewContainerRef })
  vcr!: ViewContainerRef;

  selection: SelectionType = 'ALL';
  componentRefs: ComponentRef<any>[] = [];

  cdr = inject(ChangeDetectorRef);

  async renderComponentsBySelection(selection: SelectionType) {}
}
Enter fullscreen mode Exit fullscreen mode

In PokemonTabComponent standalone component, nothing happens when clicking the radio button. However, the behaviour will change when I add new logic to call ViewContainerRef API to create components and trigger change detection. Therefore, I inject ChangeDetectorRef and assign the reference to cdr for change detection purpose.

Determine component types based on radio selection

// 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];    
}
Enter fullscreen mode Exit fullscreen mode

First, I define getComponenTypes method that loads PokemonStatsComponent and PokemonAbilitiesComponent from component files. When selection is ALL, both components are returned. When selection is STATISTICS, the method returns PokemonStatsComponent only. Otherwise, the method returns PokemonAbilitiesComponent.

Leverage ViewContainerRef API for customization in PokemonTabComponent

async renderComponentsBySelection(selection: SelectionType) {
    this.selection = selection;
    await this.renderDynamicComponents();
}
Enter fullscreen mode Exit fullscreen mode

When user clicks a radio button, inline template invokes renderComponentsBySelection to set the selection and delegate to renderDynamicComponents to call ViewContainerRef API.

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

private async renderDynamicComponents() {
    const componentTypes = await this.getComponenTypes();

    // clear dynamic components shown in the container previously    
    this.vcr.clear();
    this.destroyComponentRefs();
    for (const componentType of componentTypes) {
      const newComponentRef = this.vcr.createComponent(componentType);
      // store component refs created
      this.componentRefs.push(newComponentRef);
      // run change detection in the component and child components
      this.cdr.detectChanges();
    }
}
Enter fullscreen mode Exit fullscreen mode

When using ViewContainerRef API to create components, it is important to call clear to remove all components from the container and destroy all instances of ComponentRef to free memory. Next, ViewContainerRef iterates componentTypes to create components, keep track of ComponentRef and trigger change detection manually to display them.

If you ask where this.vcr comes from, it comes from the template variable, vcr, in the inline template.

<ng-container #vcr></ng-container>
Enter fullscreen mode Exit fullscreen mode
@ViewChild('vcr', { static: true, read: ViewContainerRef })
vcr!: ViewContainerRef;
Enter fullscreen mode Exit fullscreen mode

ViewChild selects the template variable and this.vcr has a valid ViewContainerRef object after Angular instantiates PokemonTabComponent component.

The application does not work at this point because dynamic components do not receive pokemon input during component creation. It is done on purpose. Next, I will rewrite other components to inject PokemonService and display signal value in their inline template.

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>
// pokemon-abilities.component.ts
Enter fullscreen mode Exit fullscreen mode
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

Use computed signal in PokemonPersonalComponent

// pokemon-personal.component.ts

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

Modify inline template to display personal data in personalData signal

<div class="pokemon-container" style="padding: 0.5rem;">
   <ng-container *ngTemplateOutlet="details; context: { $implicit: personalData() }"></ng-container>
</div>
<ng-template #details let-personalData>
    <label *ngFor="let data of personalData">
       <span style="font-weight: bold; color: #aaa">{{ data.text }}</span>
       <span>{{ data.value }}</span>
    </label>
</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:

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