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);
}
}
}
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) {}
}
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],
}
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;
}
}
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>
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>
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>
In the imports array, import NgComponentOutlet.
imports: [PokemonStatsComponent, PokemonAbilitiesComponent, NgFor, NgComponentOutlet],
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;
}
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
export class PokemonAbilitiesComponent {
pokemon = inject(PokemonService).pokemon;
}
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>
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.
Top comments (0)