Introduction
In Angular, ngComponentOutlet is the easy way to show components dynamically. The other option is to show dynamic components using ViewContainerRef class. ViewContainerRef class has createComponent method that creates component and puts it to a container. It is a powerful technique to add components at runtime; they are not loaded in main bundle to keep the bundle size small. When there are many components that show conditionally, ViewContainerRef solution is better than multiple ng-if expressions that quickly make the inline template messy.
In this blog post, I created a new component, PokemonTabComponent, that has radio buttons and a ng-container element. When clicking a radio button, the component shows PokemonStatsComponent, PokemonAbilitiesComponent or both dynamically. Showing dynamic components using ViewContainerRef is more complex than ngComponentOutlet and we will see how it works for the rest of the post.
The skeleton code of Pokemon Tab component
// pokemon-tab.component.ts
@Component({
selector: 'app-pokemon-tab',
standalone: true,
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>
`,
styles: [`...omitted due to brevity...`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PokemonTabComponent {
@Input()
pokemon: FlattenPokemon;
}
In PokemonTabComponent
standalone component, nothing
happened when clicking the radio buttons. However, the behavior would
change when I started to add new codes to render dynamic components
using ViewContainerRef
. The end result is to display the components in the ng-container
named vcr.
Map radio button selection to component types
// 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];
}
First, I defined a method, getComponenTypes
, to lazy load standalone components. Then, I examine the value of this.selection
to return component type list.
@ViewChild('vcr', { static: true, read: ViewContainerRef })
vcr!: ViewContainerRef;
Next, I used the ViewChild() decorator to obtain the reference to ng-container element and invoke ViewContainerRef class to append dynamic components to the container.
Add click event handler to radio buttons
When I click any radio button, I wish to look up the component types
in componentTypeMap, iterate the list to create new component references
and append them to vcr. In inline template, I add click event handler
to the radio buttons that execute renderDynamicComponents method to
render dynamic components.
// pokemon-tab.component.ts
template:`
...
<div>
<input type="radio" id="all" name="selection" value="all"
checked (click)="selection = 'ALL'; renderDynamicComponents();">
<label for="all">All</label>
</div>
<div>
<input type="radio" id="stats" name="selection" value="stats"
(click)="selection = 'STATISTICS'; renderDynamicComponents();">
<label for="stats">Stats</label>
</div>
<div>
<input type="radio" id="abilities" name="selection" value="abilities"
(click)="selection = 'ABILITIES'; renderDynamicComponents();">
<label for="abilities">Abilities</label>
</div>
...
`
selection: 'ALL' | 'STATISTICS' | 'ABILITIES' = 'ALL';
componentRefs: ComponentRef<PokemonStatsComponent | PokemonAbilitiesComponent>[] = [];
cdr = inject(ChangeDetectorRef);
async renderDynamicComponents(currentPokemon?: FlattenPokemon) {
const componentTypes = await this.getComponenTypes();
// clear dynamic components shown in the container previously
this.vcr.clear();
for (const componentType of componentTypes) {
const newComponentRef = this.vcr.createComponent(componentType);
newComponentRef.instance.pokemon = currentPokemon ? currentPokemon : this.pokemon;
// store component refs created
this.componentRefs.push(newComponentRef);
// run change detection in the component and child components
this.cdr.detectChanges();
}
}
this.selection
keeps track of the currently selected
radio button and the value determines the component/components that
get(s) rendered in the container.
this.vcr.clear();
removes all components from the container and inserts new components dynamically
const newComponentRef = this.vcr.createComponent(componentType);
instantiates and appends the new component to the container, and returns a ComponentRef.
newComponentRef.instance.pokemon = currentPokemon ? currentPokemon : this.pokemon;
PokemonStatsComponent and PokemonAbilitiesComponent expect a pokemon input; therefore, I assign a Pokemon object to newComponentRef.instance.pokemon where newComponentRef.instance is a component instance.
this.componentRefs.push(newComponentRef);
stores all the ComponentRef 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.
Destroy dynamic components in OnDestroy lifecycle hook
Implement OnDestroy interface by providing a concrete implementation of ngOnDestroy.
export class PokemonTabComponent implements OnDestroy {
...other logic...
ngOnDestroy(): void {
// release component refs to avoid memory leak
for (const componentRef of this.componentRefs) {
if (componentRef) {
componentRef.destroy();
}
}
}
}
The method iterates componentRefs array and frees the memory of each ComponentRef to avoid memory leak.
Render dynamic components in ngOnInit
When the application is initially loaded, the page is blank because it has not called renderDynamicComponents
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.renderDynamicComponents();
}
}
When Angular runs ngOnInit
, the initial value of this.selectionis ‘ALL’ and
renderDynamicComponents` displays both components at first.
Now, the initial load renders both components but I have another
problem. Button clicks and form input change do not update the Pokemon
input of the dynamic components. It can be solved by implementing OnChanges
interface and calling renderDynamicComponents again in ngOnChanges
.
Re-Render dynamic components in ngOnChanges
changes['pokemon'].currentValue
is the new Pokemon input. this.renderDynamicComponents(changes['pokemon'].currentValue)
passes the new Pokemon to assign to the new components and to display them in the container dynamically.
The following Stackblitz repo shows the final results:
I hope you like the content and continue to follow my colection experience in Angular and other technologies.
No comments:
Post a Comment