Un composant est dynamique quand vous ne savez pas à la compilation quel composant afficher — cela vient d’une configuration, du serveur, d’un CMS, des préférences de l’utilisateur. Angular vous donne trois niveaux d’outils ; la règle est de choisir le plus simple qui résout le problème.
| Outil | Quand | Inputs | Outputs | Lazy |
|---|---|---|---|---|
@if / @switch | un petit ensemble connu | ✓ normal | ✓ normal | avec @defer |
NgComponentOutlet | le composant est une valeur | ✓ inputs | ✕ | avec import() |
ViewContainerRef.createComponent | contrôle total | ✓ inputBinding | ✓ outputBinding | ✓ |
createComponent() (fonction) | hors de l’arbre (toast, dialog) | ✓ | ✓ | ✓ |
L’échelle de complexité
<!-- Niveau 1 : l’ensemble de composants est petit et connu → Control Flow simple -->
@switch (block().type) {
@case ('text') { <app-text-block [block]="block()" /> }
@case ('image') { <app-image-block [block]="block()" /> }
@case ('video') { <app-video-block [block]="block()" /> }
}
<!-- Niveau 2 : le composant est une VALEUR (elle vient de la config, d’une map) → NgComponentOutlet -->
<ng-container *ngComponentOutlet="component(); inputs: { block: block() }" />
<!-- Niveau 3 : vous avez besoin d’Outputs, de directives, d’un contrôle total → ViewContainerRef.createComponent -->Niveau 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() }));
}Niveau 3 : un tableau de bord construit à partir d’une configuration
Le pattern complet : un registre (type → import() lazy), un contrat commun (l’interface Widget) et un host qui crée le composant avec bindings (v20+). Chaque widget est un chunk séparé, téléchargé seulement quand il apparaît dans la configuration.
// Registre : type → lazy loading. Chaque widget est un chunk séparé.
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),
};
// Le contrat commun — chaque widget a title et remove
export interface Widget {
readonly title: InputSignal<string>;
readonly remove: OutputEmitterRef<void>;
}„Un petit composant est un composant facile à tester.”
Configuration actuelle (elle pourrait venir du serveur)
[
{
"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"
}
]Les bindings relient les Inputs à des Signals ou des fonctions — ils se mettent à jour tout seuls, sans appels répétés à setInput() — et les Outputs à des fonctions, sans subscribe/unsubscribe manuels. Vous pouvez aussi attacher des directives au composant créé (directives: [...]). En v22, ComponentFactoryResolver et ComponentFactory ont été supprimés : vous passez directement la classe.
// ✗ Avant la v20 : setInput + subscribe manuel + ComponentFactoryResolver (supprimé en v22 !)
const factory = this.resolver.resolveComponentFactory(StatsWidget); // n’existe plus en v22
const ref = this.vcr.createComponent(factory);
ref.setInput('title', this.config.title); // à rappeler à chaque changement
const sub = ref.instance.remove.subscribe(() => this.removed.emit());
// + unsubscribe manuel à la destructionHors de l’arbre : createComponent()
Pour des toasts, des dialogs ou des overlays, le composant doit être attaché directement à <body>, et non là où il est appelé. La fonction createComponent + ApplicationRef.attachView s’en chargent. (Dans les vraies applications, on utilise souvent @angular/cdk/overlay ou le Dialog du CDK, qui font exactement cela pour vous.)
// Un composant créé hors de l’arbre (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); // entre dans la Change Detection
document.body.appendChild(ref.location.nativeElement);
setTimeout(close, ms);
}
}Si vous voulez seulement charger un composant plus tard, @defer — Lazy Loading dans les templates est plus simple. Les erreurs des composants créés dynamiquement se capturent avec onError : Erreurs : @boundary et ErrorHandler.
Quand OUI, quand NON
- Une mise en page configurable par l’utilisateur (tableau de bord, widgets).
- Des pages construites depuis un CMS / page builder (un bloc « texte », « galerie », « formulaire »).
- Des formulaires générés à partir d’un schéma JSON.
- Des plugins : des modules ajoutés sans modifier le composant hôte.
- Des overlays : toast, dialog, tooltip, menu contextuel.
- Vous avez 2 à 5 variantes connues →
@switchest plus simple, plus lisible et vérifié à la compilation. - Vous voulez seulement du lazy loading →
@deferouloadComponentsur une route. - Vous voulez seulement masquer/afficher quelque chose →
@if.
Pièges fréquents
- Vous perdez la vérification de types : le nom dans
inputBinding('title', ...)est une chaîne. Définissez un contrat (une interface) et gardez-le près du registre. - Fuites mémoire avec
createComponenthors de l’arbre : n’oubliez pasref.destroy()etdetachView. - Recréation à chaque changement : ne recréez le composant que lorsque le type change ; le reste passe par des bindings réactifs.
- L’injector : un composant créé hérite de l’injector du
ViewContainerRef. Pour un autre contexte, passez uninjectordans les options.