Front-end angularforall.com

- Création dynamique de composants Angular

Angular Viewcontainerref Createcomponent Composants-Dynamiques Modale Ngcomponentoutlet Componentref Setinput Destroyref Angular-19 Tutoriel Architecture
Création dynamique de composants Angular

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.
Avant d'opter pour la création dynamique : demandez-vous si @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'option read indique qu'on veut le ViewContainerRef et non l'élément DOM.
  • createComponent(MyComponent) retourne un ComponentRef qui 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.
Pourquoi 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(...);
}
setInput est crucial : n'écrivez jamais 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();
        });
    }
}
En production, préférez Angular CDK Dialog (@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.id pour optimiser le re-render avec @for.
Lazy loading des widgets : pour éviter d'inclure tous les types de widgets dans le bundle initial, utilisez 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èreNgComponentOutletcreateComponent
StyleDéclaratif (template)Impératif (TS)
Passage d'inputsObject literalsetInput typé
Écoute d'outputs❌ Limité✅ Direct sur l'instance
Plusieurs instances❌ Une seule✅ Illimité
Cas d'usageSwitch entre composantsModales, 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() ou destroy() reste utile pour vider avant.
  • Via createComponent + ApplicationRef : nettoyage manuel obligatoire. Toujours appeler detachView puis destroy.
  • Subscriptions sur outputs : à unsubscribe() ou utiliser takeUntilDestroyed().
// 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());
    }
}
Memory leak classique : ouvrir une modale, naviguer vers une autre route sans la fermer. Le ComponentRef reste accroché à l'ApplicationRef et ne sera jamais garbage-collecté. 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 à createComponent direct quand possible
  • Toujours utiliser setInput() pour passer les inputs (jamais instance.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
Pour aller plus loin : consultez la documentation officielle angular.dev — Dynamic components et l'article de blog.angulartraining.com sur le sujet.

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 affecter ref.instance.x = ..., cela contourne le change detection.
  • Cleanup garanti : takeUntilDestroyed() sur les outputs, ref.destroy() et appRef.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.
Pour aller plus loin : consultez la nouvelle API input()/output() pour passer des données type-safe à vos composants dynamiques, et @defer avancé pour orchestrer le chargement à la demande.

Partager