O componentă e dinamică când nu știi la compilare ce componentă trebuie afișată — vine dintr-o configurație, de la server, dintr-un CMS, din preferințele utilizatorului. Angular îți dă trei niveluri de unelte; regula e să o alegi pe cea mai simplă care rezolvă problema.
| Unealtă | Când | Inputs | Outputs | Lazy |
|---|---|---|---|---|
@if / @switch | set mic, cunoscut | ✓ normal | ✓ normal | cu @defer |
NgComponentOutlet | componenta e o valoare | ✓ inputs | ✕ | cu import() |
ViewContainerRef.createComponent | control total | ✓ inputBinding | ✓ outputBinding | ✓ |
createComponent() (funcție) | în afara arborelui (toast, dialog) | ✓ | ✓ | ✓ |
Scara complexității
<!-- Nivel 1: setul de componente e mic și cunoscut → control flow simplu -->
@switch (block().type) {
@case ('text') { <app-text-block [block]="block()" /> }
@case ('image') { <app-image-block [block]="block()" /> }
@case ('video') { <app-video-block [block]="block()" /> }
}
<!-- Nivel 2: componenta e o VALOARE (vine din config, dintr-un map) → NgComponentOutlet -->
<ng-container *ngComponentOutlet="component(); inputs: { block: block() }" />
<!-- Nivel 3: ai nevoie de output-uri, directive, control total → ViewContainerRef.createComponent -->Nivelul 2: NgComponentOutlet
import { NgComponentOutlet } from '@angular/common';
@Component({
imports: [NgComponentOutlet],
template: `
<ng-container *ngComponentOutlet="component(); inputs: inputs()" />
`,
})
export class BlockRenderer {
readonly block = input.required<Block>();
protected readonly component = computed(() => BLOCKS[this.block().type]); // Type<unknown>
protected readonly inputs = computed(() => ({ block: this.block() }));
}Nivelul 3: dashboard construit din configurație
Pattern-ul complet: un registru (tip → import() lazy), un contract comun (interfața Widget) și un host care creează componenta cu bindings (v20+). Fiecare widget e un chunk separat, descărcat doar când apare în configurație.
// Registrul: tip → încărcare lazy. Fiecare widget e un chunk separat.
export const WIDGET_REGISTRY: Record<WidgetType, () => Promise<Type<Widget>>> = {
stats: () => import('./stats-widget').then((m) => m.StatsWidget),
chart: () => import('./chart-widget').then((m) => m.ChartWidget),
quote: () => import('./quote-widget').then((m) => m.QuoteWidget),
todo: () => import('./todo-widget').then((m) => m.TodoWidget),
};
// Contractul comun — orice widget are title și remove
export interface Widget {
readonly title: InputSignal<string>;
readonly remove: OutputEmitterRef<void>;
}„Starea derivată nu se stochează, se calculează.”
Configurația curentă (asta ar putea veni de la server)
[
{
"id": 1,
"type": "stats",
"title": "dynamic-components:today"
},
{
"id": 2,
"type": "chart",
"title": "dynamic-components:traffic"
},
{
"id": 3,
"type": "quote",
"title": "dynamic-components:quote-of-the-day"
}
]bindings leagă input-urile la semnale sau funcții — se actualizează singure, fără setInput() repetat — iar output-urile la funcții, fără subscribe/unsubscribe manual. Poți atașa și directive la componenta creată (directives: [...]). În v22, ComponentFactoryResolver și ComponentFactory au fost eliminate: dai clasa direct.
// ✗ Înainte de v20: setInput + subscribe manual + ComponentFactoryResolver (eliminat în v22!)
const factory = this.resolver.resolveComponentFactory(StatsWidget); // nu mai există în v22
const ref = this.vcr.createComponent(factory);
ref.setInput('title', this.config.title); // trebuie re-apelat la fiecare schimbare
const sub = ref.instance.remove.subscribe(() => this.removed.emit());
// + dezabonare manuală la destroyÎn afara arborelui: createComponent()
Pentru toast-uri, dialoguri sau overlay-uri, componenta trebuie atașată direct de <body>, nu în locul unde e apelată. Funcția createComponent + ApplicationRef.attachView fac asta. (În aplicații reale, de multe ori folosești @angular/cdk/overlay sau Dialog din CDK, care fac exact asta pentru tine.)
// Componentă creată în afara arborelui (dialog, toast, tooltip global)
@Service()
export class ToastService {
private readonly appRef = inject(ApplicationRef);
private readonly injector = inject(EnvironmentInjector);
show(message: string, kind: 'info' | 'ok' | 'bad' = 'info', ms = 3000) {
const ref = createComponent(Toast, {
environmentInjector: this.injector,
bindings: [
inputBinding('message', () => message),
inputBinding('kind', () => kind),
outputBinding('closed', () => close()),
],
});
const close = () => {
this.appRef.detachView(ref.hostView);
ref.destroy();
};
this.appRef.attachView(ref.hostView); // intră în change detection
document.body.appendChild(ref.location.nativeElement);
setTimeout(close, ms);
}
}Dacă vrei doar să încarci o componentă mai târziu, @defer — lazy loading în template e mai simplu. Erorile din componentele create dinamic se prind cu onError: Erori: @boundary & ErrorHandler.
Când DA, când NU
- Layout configurabil de utilizator (dashboard, widget-uri).
- Pagini construite dintr-un CMS / page builder (bloc „text”, „galerie”, „formular”).
- Formulare generate din JSON schema.
- Plugin-uri: module adăugate fără să modifici componenta host.
- Overlay-uri: toast, dialog, tooltip, meniu contextual.
- Ai 2–5 variante cunoscute →
@switche mai simplu, mai lizibil și verificat la compilare. - Vrei doar lazy loading →
@defersauloadComponentpe rută. - Vrei doar să ascunzi/arăți ceva →
@if.
Capcane frecvente
- Pierzi verificarea de tipuri: numele din
inputBinding('title', ...)e un string. Definește un contract (interfață) și ține-l aproape de registru. - Memory leaks cu
createComponentîn afara arborelui: nu uitaref.destroy()șidetachView. - Re-creare la fiecare schimbare: re-creează componenta doar când se schimbă tipul; restul trece prin binding-uri reactive.
- Injectorul: componenta creată moștenește injectorul
ViewContainerRef-ului. Pentru alt context, dăinjectorîn opțiuni.