Maîtrisez la création dynamique de composants Angular avec ViewContainerRef et createComponent : modales, dashboards configurables et cas réels en production.
Quand créer un composant dynamiquement
La majorité des composants Angular sont insérés statiquement dans des templates. Mais certaines situations demandent une instanciation à l'exécution, sans connaître à l'avance le composant à afficher.
Cas où la création dynamique est indispensable :
- Modales et notifications : un service ouvre une boîte de dialogue avec un composant arbitraire, depuis n'importe où dans l'app.
- Dashboards configurables : l'utilisateur choisit ses widgets ; chaque cellule rend le composant correspondant.
- Form builders : générer dynamiquement des champs à partir d'un schéma JSON.
- Bulles de chat : injecter des composants riches (cartes, sondages, médias) dans une liste de messages.
- Plugin systems : charger un composant fourni par un module tiers.
@switch + @case sur un signal ne suffirait pas. Si vous connaissez l'ensemble fini des composants possibles, le control flow déclaratif est plus simple à comprendre, à tester et à debugger.
L'API moderne : ViewContainerRef.createComponent
Depuis Angular 13, l'API recommandée est ViewContainerRef.createComponent(ComponentClass). Elle remplace l'ancien ComponentFactoryResolver (déprécié) et fonctionne nativement avec Ivy et les composants standalone.
Premier exemple — bouton qui injecte un composant :
// hello.component.ts (composant à instancier)
import { Component } from '@angular/core';
@Component({
selector: 'app-hello',
standalone: true,
template: `<p class="hello">Bonjour depuis le composant dynamique !</p>`,
styles: [`.hello { color: var(--brand); padding: .5rem; }`]
})
export class HelloComponent {}
// host.component.ts (composant qui instancie)
import { Component, ViewContainerRef, viewChild, inject } from '@angular/core';
import { HelloComponent } from './hello.component';
@Component({
selector: 'app-host',
standalone: true,
template: `
<button (click)="addHello()">Ajouter un Hello</button>
<button (click)="clear()">Vider</button>
<!-- Conteneur où les composants seront ajoutés -->
<ng-container #anchor></ng-container>
`
})
export class HostComponent {
// Récupère le ViewContainerRef du conteneur via viewChild signal
private anchor = viewChild('anchor', { read: ViewContainerRef });
addHello() {
// Crée et insère le composant dans le conteneur
const ref = this.anchor()?.createComponent(HelloComponent);
// ref est un ComponentRef<HelloComponent>
// ref.instance donne accès à l'instance du composant
// ref.location.nativeElement donne accès au DOM
// ref.destroy() retire le composant
}
clear() {
// Vide tous les composants insérés
this.anchor()?.clear();
}
}
Les points clés :
viewChild('anchor', { read: ViewContainerRef })— l'optionreadindique qu'on veut leViewContainerRefet non l'élément DOM.createComponent(MyComponent)retourne unComponentRefqui contient l'instance et les méthodes de cycle de vie.clear()détruit tous les composants enfants en une seule opération — pratique pour les listes dynamiques.
ng-container et pas div ? ng-container n'ajoute aucun élément DOM. Le composant créé dynamiquement est inséré après ce repère, pas dedans. Cela évite un wrapper inutile.
Passer des inputs et écouter des outputs
L'intérêt réel des composants dynamiques apparaît dès qu'on doit leur passer des données et réagir à leurs événements.
Composant cible avec inputs et outputs :
// confirm-dialog.component.ts
import { Component, input, output } from '@angular/core';
@Component({
selector: 'app-confirm-dialog',
standalone: true,
template: `
<div class="dialog">
<h3>{{ title() }}</h3>
<p>{{ message() }}</p>
<button (click)="confirmed.emit(true)">Oui</button>
<button (click)="confirmed.emit(false)">Non</button>
</div>
`
})
export class ConfirmDialogComponent {
// Inputs typés via la nouvelle API signal
readonly title = input<string>('Confirmer');
readonly message = input<string>('Êtes-vous sûr ?');
// Output émetteur d'événements
readonly confirmed = output<boolean>();
}
Création dynamique avec passage de données :
import { ComponentRef, inject, ViewContainerRef, viewChild } from '@angular/core';
import { ConfirmDialogComponent } from './confirm-dialog.component';
@Component({ /* ... */ })
export class HostComponent {
private anchor = viewChild('anchor', { read: ViewContainerRef });
private currentDialog: ComponentRef<ConfirmDialogComponent> | null = null;
showDialog(title: string, message: string): Promise<boolean> {
// Détruire l'éventuelle modale précédente
this.currentDialog?.destroy();
// Créer le composant
const ref = this.anchor()!.createComponent(ConfirmDialogComponent);
// Passer les inputs via setInput (Angular 14.1+)
// Cette méthode déclenche correctement les hooks et le change detection
ref.setInput('title', title);
ref.setInput('message', message);
this.currentDialog = ref;
return new Promise(resolve => {
// Écouter l'output via instance.outputName
const sub = ref.instance.confirmed.subscribe(result => {
sub.unsubscribe(); // Cleanup
ref.destroy(); // Retirer la modale
this.currentDialog = null;
resolve(result);
});
});
}
}
// Utilisation
async deleteItem() {
const ok = await this.showDialog('Suppression', 'Confirmer la suppression ?');
if (ok) this.api.delete(...);
}
ref.instance.title = 'value' directement. Cela contourne le système d'inputs Angular et peut casser OnChanges, computed dérivés ou les hooks. Toujours passer par setInput.
Construire un service de modale
Le pattern le plus utile : un service global qui ouvre une modale depuis n'importe quel composant. Angular CDK fournit déjà @angular/cdk/dialog, mais comprendre l'implémentation interne est précieux.
// modal.service.ts
import {
ApplicationRef, ComponentRef, EnvironmentInjector,
Injectable, Type, createComponent, inject
} from '@angular/core';
@Injectable({ providedIn: 'root' })
export class ModalService {
private appRef = inject(ApplicationRef);
private injector = inject(EnvironmentInjector);
private currentRef: ComponentRef<any> | null = null;
open<T extends object>(component: Type<T>, inputs: Partial<T> = {}): ComponentRef<T> {
// Fermer une modale précédente si elle existe
this.close();
// Créer le composant en dehors d'un ViewContainerRef
// createComponent (depuis @angular/core) accepte un environment injector
const ref = createComponent(component, {
environmentInjector: this.injector,
// L'élément hôte sera ajouté manuellement au DOM
hostElement: this.createHost()
});
// Passer les inputs
Object.entries(inputs).forEach(([key, value]) => {
ref.setInput(key, value);
});
// Attacher au cycle de change detection
this.appRef.attachView(ref.hostView);
this.currentRef = ref;
return ref;
}
close() {
if (!this.currentRef) return;
// Détacher du change detection avant destruction
this.appRef.detachView(this.currentRef.hostView);
this.currentRef.destroy();
this.currentRef = null;
}
private createHost(): HTMLElement {
// Créer un overlay positionné fixed au-dessus de tout
const host = document.createElement('div');
host.classList.add('af-modal-overlay');
document.body.appendChild(host);
return host;
}
}
CSS de l'overlay :
.af-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: grid;
place-items: center;
z-index: 1000;
}
Utilisation dans un composant :
@Component({ /* ... */ })
export class ProductPageComponent {
private modal = inject(ModalService);
openConfirm() {
const ref = this.modal.open(ConfirmDialogComponent, {
title: 'Supprimer ce produit ?',
message: 'Cette action est irréversible.'
});
ref.instance.confirmed.subscribe(ok => {
if (ok) this.deleteProduct();
this.modal.close();
});
}
}
@angular/cdk/dialog). Il gère pour vous : focus trap, ARIA, escape key, scroll lock, cleanup. Mais comprendre le pattern manuel reste précieux pour debug et cas atypiques.
Dashboard avec widgets configurables
Imaginons un dashboard où l'utilisateur configure ses widgets parmi un catalogue. La configuration est stockée en BDD ; il faut instancier dynamiquement les bons composants au chargement.
// Type définissant la configuration
type WidgetConfig = {
id: string;
type: 'chart' | 'table' | 'metric' | 'feed';
title: string;
config: Record<string, any>;
};
// Map type → composant (registry)
const WIDGET_REGISTRY: Record<WidgetConfig['type'], Type<any>> = {
chart: ChartWidgetComponent,
table: TableWidgetComponent,
metric: MetricWidgetComponent,
feed: FeedWidgetComponent
};
@Component({
selector: 'app-dashboard',
standalone: true,
template: `
<div class="dashboard-grid">
@for (widget of widgets(); track widget.id) {
<div class="widget-cell" [attr.data-id]="widget.id">
<ng-container #cell></ng-container>
</div>
}
</div>
`
})
export class DashboardComponent implements AfterViewInit {
readonly widgets = signal<WidgetConfig[]>([]);
private cells = viewChildren('cell', { read: ViewContainerRef });
private refs: ComponentRef<any>[] = [];
constructor() {
// Recharger les widgets quand la liste change
effect(() => {
const list = this.widgets();
const containers = this.cells();
// Attendre que les ViewContainerRef soient prêts
if (containers.length === list.length) {
this.renderAll(list, containers);
}
});
}
ngAfterViewInit() {
this.fetchUserWidgets();
}
private renderAll(list: WidgetConfig[], containers: readonly ViewContainerRef[]) {
// Détruire les anciens composants
this.refs.forEach(r => r.destroy());
this.refs = [];
list.forEach((widget, i) => {
const ComponentType = WIDGET_REGISTRY[widget.type];
if (!ComponentType) return;
// Créer chaque widget
const ref = containers[i].createComponent(ComponentType);
ref.setInput('title', widget.title);
ref.setInput('config', widget.config);
this.refs.push(ref);
});
}
fetchUserWidgets() {
// Charger depuis l'API
this.api.getWidgets().subscribe(w => this.widgets.set(w));
}
}
Cet exemple combine plusieurs concepts modernes :
viewChildren(Angular 17+) pour récupérer plusieurs ViewContainerRef en signal.effect()pour réagir à un changement de configuration.- Un registre type → composant pour découpler la configuration de l'implémentation.
track widget.idpour optimiser le re-render avec @for.
import('./widgets/chart').then(m => m.ChartWidgetComponent) et créez le composant après résolution du Promise. Cela diminue significativement le First Load JS.
Alternative déclarative : NgComponentOutlet
Pour des cas simples (un seul composant à insérer, peu d'inputs), NgComponentOutlet est plus concis qu'une API impérative.
import { NgComponentOutlet } from '@angular/common';
@Component({
selector: 'app-renderer',
standalone: true,
imports: [NgComponentOutlet],
template: `
<ng-container
*ngComponentOutlet="
currentComponent();
inputs: currentInputs();
">
</ng-container>
`
})
export class RendererComponent {
readonly type = signal<'a' | 'b'>('a');
readonly currentComponent = computed(() =>
this.type() === 'a' ? ComponentA : ComponentB
);
readonly currentInputs = computed(() => ({
title: 'Titre dynamique',
items: [1, 2, 3]
}));
}
NgComponentOutlet supporte depuis Angular 16.2 le passage d'inputs via le binding inputs. C'est l'outil idéal pour switcher entre composants connus à l'avance, sans gérer manuellement les ComponentRef.
| Critère | NgComponentOutlet | createComponent |
|---|---|---|
| Style | Déclaratif (template) | Impératif (TS) |
| Passage d'inputs | Object literal | setInput typé |
| Écoute d'outputs | ❌ Limité | ✅ Direct sur l'instance |
| Plusieurs instances | ❌ Une seule | ✅ Illimité |
| Cas d'usage | Switch entre composants | Modales, dashboards |
Destruction et nettoyage mémoire
Les composants créés dynamiquement ne sont pas automatiquement détruits avec leur composant parent si ils ont été créés via createComponent avec un environmentInjector et attachés manuellement à l'ApplicationRef.
Règles de cleanup :
- Via ViewContainerRef : nettoyage automatique quand le parent est détruit.
clear()oudestroy()reste utile pour vider avant. - Via createComponent + ApplicationRef : nettoyage manuel obligatoire. Toujours appeler
detachViewpuisdestroy. - Subscriptions sur outputs : à
unsubscribe()ou utilisertakeUntilDestroyed().
// Pattern de cleanup avec DestroyRef (Angular 16+)
import { DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({ /* ... */ })
export class HostComponent {
private destroyRef = inject(DestroyRef);
private modal = inject(ModalService);
open() {
const ref = this.modal.open(ConfirmDialogComponent, { title: 'Test' });
// Subscription auto-cleanup quand HostComponent est détruit
ref.instance.confirmed
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(result => { /* ... */ });
// Détruire la modale aussi quand le parent est détruit
this.destroyRef.onDestroy(() => this.modal.close());
}
}
destroyRef.onDestroy() est votre garde-fou.
Tester un composant créé dynamiquement
Tester du code qui crée des composants dynamiquement demande un peu plus de boilerplate, mais reste accessible.
// host.component.spec.ts
import { TestBed } from '@angular/core/testing';
import { HostComponent } from './host.component';
import { HelloComponent } from './hello.component';
describe('HostComponent', () => {
it('crée un HelloComponent au clic', () => {
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
// Avant le clic : aucun HelloComponent
let helloElements = fixture.nativeElement.querySelectorAll('app-hello');
expect(helloElements.length).toBe(0);
// Cliquer sur le bouton "Ajouter"
const button = fixture.nativeElement.querySelector('button');
button.click();
fixture.detectChanges();
// Vérifier l'apparition
helloElements = fixture.nativeElement.querySelectorAll('app-hello');
expect(helloElements.length).toBe(1);
expect(helloElements[0].textContent).toContain('Bonjour');
});
it('vide les composants au clic Vider', () => {
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
const [addBtn, clearBtn] = fixture.nativeElement.querySelectorAll('button');
addBtn.click(); addBtn.click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelectorAll('app-hello').length).toBe(2);
clearBtn.click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelectorAll('app-hello').length).toBe(0);
});
});
- Préférer
ViewContainerRef.createComponentàcreateComponentdirect quand possible - Toujours utiliser
setInput()pour passer les inputs (jamaisinstance.x = ...) - Souscrire aux outputs avec
takeUntilDestroyed() - Détruire manuellement les ComponentRef créés via
ApplicationRef.attachView - Lazy-loader les composants peu fréquents pour réduire le bundle initial
- Utiliser CDK Dialog/Overlay en production plutôt que de réinventer une modale
Conclusion : quand et comment l'utiliser
La création dynamique de composants est un outil puissant mais à doser. Pour 90 % des cas, le control flow déclaratif (@if, @switch, @for) ou NgComponentOutlet font mieux : moins de code, plus testable, plus prévisible. Réservez ViewContainerRef.createComponent() aux véritables systèmes ouverts : modales globales, dashboards configurables, plugin systems.
À retenir pour la production :
setInput()obligatoire : ne jamais affecterref.instance.x = ..., cela contourne le change detection.- Cleanup garanti :
takeUntilDestroyed()sur les outputs,ref.destroy()etappRef.detachView()en symétrie. - NgComponentOutlet en alternative : pour un set fini de composants connus, c'est plus simple et déclaratif.
- CDK Dialog/Overlay en production : ne réinventez pas une modale, le CDK gère focus trap, ARIA et z-index.
- Lazy-load les composants peu fréquents avec
import()dynamique pour ne pas alourdir le bundle initial.
input()/output() pour passer des données type-safe à vos composants dynamiques, et @defer avancé pour orchestrer le chargement à la demande.