📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

~7 min de lecture

ngComponentOutlet en Angular 20 : rendre des composants pilotés par la donnée, sans forêt de @switch

Tu construis un dashboard. Chaque tuile est un composant différent : un graphe, un compteur, une liste, une carte météo. La composition n'est pas connue à l'écriture, elle vient d'une config utilisateur ou d'une API. Alors tu fais ce que tout le monde fait la première fois :

@for (widget of widgets(); track widget.id) {
  @switch (widget.type) {
    @case ('chart') { <app-chart-widget [data]="widget.data" /> }
    @case ('counter') { <app-counter-widget [value]="widget.value" /> }
    @case ('list') { <app-list-widget [items]="widget.items" /> }
    @case ('weather') { <app-weather-widget [city]="widget.city" /> }
  }
}

Ça marche. Puis il y a 12 types de tuiles, chaque nouveau widget impose de toucher ce @switch, l'imports du composant hôte gonfle, et le composant qui devrait juste orchestrer connaît maintenant l'existence de chaque widget de l'app. Ton composant de layout est devenu un annuaire.

ngComponentOutlet inverse le problème : au lieu de brancher chaque type à la main dans le template, tu rends le composant que la donnée désigne, à partir de sa classe. Le layout ne connaît plus les widgets, il connaît juste "rends ce composant avec ces inputs".

Valide Angular 16.1+ pour ngComponentOutletInputs, exemples en Angular 20. Les inputs dynamiques (le point qui rend la directive vraiment utilisable) sont arrivés en 16.1 ; avant, il fallait passer par un injector. Si tu es sur une version antérieure, le pattern registry tient toujours, mais tu passes les données autrement.


TL;DR

Besoin Ce que tu utilises
Rendre un composant choisi au runtime [ngComponentOutlet]="componentClass"
Lui passer des inputs [ngComponentOutletInputs]="{ ... }" (16.1+)
Lui donner un injector dédié (DI scopée) [ngComponentOutletInjector]="injector"
Typer le couple composant + inputs Une factory par variante, pas la directive
Récupérer des outputs / un ComponentRef Pas la directive : ViewContainerRef.createComponent

Règle d'or : ngComponentOutlet gère les inputs, jamais les outputs. Le jour où ton composant dynamique doit émettre vers l'hôte, tu sors de la directive et tu passes en impératif.


Solution : la syntaxe binding, pas la structurelle

L'ancien réflexe, c'est *ngComponentOutlet en directive structurelle. Ça fonctionne, mais dès que tu as plus d'un paramètre (une classe et des inputs et un injector), la microsyntaxe structurelle devient illisible. Préfère la forme binding sur <ng-container> :

import { ChangeDetectionStrategy, Component, input, Type } from '@angular/core';
import { NgComponentOutlet } from '@angular/common';

@Component({
  selector: 'app-widget-host',
  imports: [NgComponentOutlet],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <ng-container
      [ngComponentOutlet]="component()"
      [ngComponentOutletInputs]="inputs()"
    />
  `,
})
export class WidgetHost {
  component = input.required<Type<unknown>>();
  inputs = input<Record<string, unknown>>({});
}

Ce composant hôte ne connaît aucun widget concret. Il reçoit une classe et un objet d'inputs, et Angular instancie, insère, et câble les inputs pour toi. Quand inputs() change, Angular diffe le record et met à jour les inputs concernés.

Le layout devient trivial :

@for (widget of widgets(); track widget.id) {
  <app-widget-host [component]="widget.component" [inputs]="widget.inputs" />
}

Plus de @switch. Ajouter un widget ne touche plus le layout : il suffit de le déclarer dans la donnée.


Pattern 1 : typer le couple composant + inputs à la construction

Le point faible saute aux yeux : ngComponentOutletInputs prend un Record<string, unknown>. Rien ne vérifie que les clés correspondent aux vrais inputs du composant, ni que les valeurs ont le bon type. Tu peux écrire { valeu: 42 } au lieu de { value: 42 }, ça compile, et le composant reçoit un input qui n'existe pas sans broncher.

La directive ne peut pas t'aider là-dessus : elle reçoit un Type<unknown>, elle ne sait pas quels inputs ce composant expose. La sécurité, tu la remets au point de construction de la donnée, pas dans le template. Une factory typée par variante :

import { Type } from '@angular/core';

interface WidgetDescriptor {
  id: string;
  component: Type<unknown>;
  inputs: Record<string, unknown>;
}

// Une factory par widget : le type est verifie ici, une fois pour toutes
function counterWidget(id: string, value: number): WidgetDescriptor {
  return { id, component: CounterWidget, inputs: { value } };
}

function weatherWidget(id: string, city: string): WidgetDescriptor {
  return { id, component: WeatherWidget, inputs: { city } };
}

Le call-site est maintenant typé : counterWidget('cpu', 42) refuse counterWidget('cpu', 'quarante-deux'). Le Record<string, unknown> reste, mais il est produit par une fonction dont la signature est vérifiée. Tu ne perds le typage que sur les trois lignes de la factory, au lieu de le perdre partout où tu déclares un widget.

export class Dashboard {
  protected readonly widgets = signal<WidgetDescriptor[]>([
    counterWidget('active-users', 1240),
    weatherWidget('office', 'Lyon'),
  ]);
}

C'est le compromis honnête avec ngComponentOutlet : la directive ne type pas ses inputs, alors tu contiens le risque à la frontière où la donnée est fabriquée.


Pattern 2 : un registry pour mapper une donnée serveur vers un composant

Le cas réel n'est pas "j'ai déjà les classes sous la main", c'est "l'API me renvoie type: 'weather' et je dois choisir le composant". D'où un registry : une table qui associe un discriminant à une classe.

import { Type } from '@angular/core';

type WidgetType = 'counter' | 'weather' | 'list';

const WIDGET_REGISTRY: Record<WidgetType, Type<unknown>> = {
  counter: CounterWidget,
  weather: WeatherWidget,
  list: ListWidget,
};

Le composant hôte résout la classe depuis le type au moment du rendu :

@Component({
  selector: 'app-dashboard',
  imports: [NgComponentOutlet],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @for (widget of widgets(); track widget.id) {
      <ng-container
        [ngComponentOutlet]="resolve(widget.type)"
        [ngComponentOutletInputs]="widget.inputs"
      />
    }
  `,
})
export class Dashboard {
  protected readonly widgets = input.required<ServerWidget[]>();

  protected resolve(type: WidgetType): Type<unknown> {
    return WIDGET_REGISTRY[type];
  }
}

Ajouter un widget = une entrée dans le registry + une factory. Zéro ligne dans le template, zéro @switch, et le layout ne gagne pas une seule dépendance de plus dans son imports (les widgets sont référencés par le registry, pas importés dans le composant). C'est là que ngComponentOutlet paye vraiment : la surface de modification pour un nouveau type tombe à une ligne de mapping.

Combine avec @defer si le registry pointe vers des composants lourds : tu peux charger la classe à la demande et ne rendre l'outlet qu'une fois résolue, pour ne pas embarquer 12 widgets dans le bundle initial.


Pattern 3 : un injector dédié pour scoper la DI du composant rendu

Parfois le composant dynamique a besoin d'un service configuré spécifiquement pour lui : un token de contexte, une config de thème, une source de données isolée. ngComponentOutletInjector te laisse fournir un injector qui devient le parent du composant créé.

import { ChangeDetectionStrategy, Component, computed, inject, Injector, input, Type } from '@angular/core';
import { NgComponentOutlet } from '@angular/common';

@Component({
  selector: 'app-widget-host',
  imports: [NgComponentOutlet],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <ng-container
      [ngComponentOutlet]="component()"
      [ngComponentOutletInjector]="widgetInjector()"
    />
  `,
})
export class WidgetHost {
  private readonly parentInjector = inject(Injector);

  component = input.required<Type<unknown>>();
  config = input.required<WidgetConfig>();

  protected readonly widgetInjector = computed(() =>
    Injector.create({
      providers: [{ provide: WIDGET_CONFIG, useValue: this.config() }],
      parent: this.parentInjector,
    }),
  );
}

Le composant rendu peut alors faire inject(WIDGET_CONFIG) et recevoir la config propre à cette instance, sans que tu aies à la passer en input. Utile pour les widgets qui partagent une même classe mais tournent dans des contextes différents (multi-tenant, previews, sandbox).


La limite à connaître : pas d'outputs

Voilà le mur contre lequel tout le monde finit par se cogner. ngComponentOutlet câble les inputs, jamais les outputs. Il n'existe pas de ngComponentOutletOutputs. Si ton CounterWidget a un output() removed et que l'hôte doit réagir à la suppression, la directive ne te donne aucune prise dessus.

Le jour où tu as besoin d'écouter un output, d'appeler une méthode sur l'instance, ou de garder une référence vers le composant, tu quittes la directive et tu passes par ViewContainerRef.createComponent, qui te rend un ComponentRef complet :

import { ChangeDetectionStrategy, Component, Type, ViewContainerRef, viewChild } from '@angular/core';

@Component({
  selector: 'app-imperative-host',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<ng-container #slot />`,
})
export class ImperativeHost {
  private readonly slot = viewChild.required('slot', { read: ViewContainerRef });

  protected render(component: Type<CounterWidget>, value: number): void {
    const ref = this.slot().createComponent(component);
    ref.setInput('value', value);
    // Ici tu as acces aux outputs, ce que ngComponentOutlet ne permet pas
    ref.instance.removed.subscribe(() => ref.destroy());
  }
}

La règle de décision est simple : flux descendant seulement (des inputs, pas de retour) -> ngComponentOutlet. Besoin d'un canal remontant (outputs, appels de méthode, ref) -> createComponent. Ne force pas la directive à faire ce pour quoi elle n'est pas prévue en bricolant un service partagé juste pour récupérer un événement : c'est le signal qu'il fallait passer impératif.


Before / After

Avant, le layout connaît tout le monde :

@Component({
  selector: 'app-dashboard',
  imports: [ChartWidget, CounterWidget, ListWidget, WeatherWidget],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @for (widget of widgets(); track widget.id) {
      @switch (widget.type) {
        @case ('chart') { <app-chart-widget [data]="widget.data" /> }
        @case ('counter') { <app-counter-widget [value]="widget.value" /> }
        @case ('list') { <app-list-widget [items]="widget.items" /> }
        @case ('weather') { <app-weather-widget [city]="widget.city" /> }
      }
    }
  `,
})
export class Dashboard { /* ... */ }

Chaque nouveau widget = un @case, un import, et un layout qui grossit sans fin.

Après, le layout ne connaît plus rien :

@Component({
  selector: 'app-dashboard',
  imports: [NgComponentOutlet],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @for (widget of widgets(); track widget.id) {
      <ng-container
        [ngComponentOutlet]="WIDGET_REGISTRY[widget.type]"
        [ngComponentOutletInputs]="widget.inputs"
      />
    }
  `,
})
export class Dashboard {
  protected readonly WIDGET_REGISTRY = WIDGET_REGISTRY;
  protected readonly widgets = input.required<ServerWidget[]>();
}

Un nouveau widget = une entrée de registry + une factory typée. Le layout ne change pas. Il est fermé à la modification, ouvert à l'extension, sans que tu aies eu à le décréter.


Récap actionnable

  1. Utilise la syntaxe binding [ngComponentOutlet] sur <ng-container>, pas la structurelle *ngComponentOutlet, dès que tu passes plus d'un paramètre.
  2. Passe les inputs via [ngComponentOutletInputs] (Angular 16.1+). Angular diffe le record et met à jour les inputs concernés.
  3. Type le couple composant + inputs dans une factory, pas dans le template. La directive ne peut pas vérifier un Record<string, unknown>, alors contiens le risque à la construction.
  4. Centralise le mapping type -> composant dans un registry. Un nouveau type coûte une ligne, le layout ne gagne aucune dépendance.
  5. Scope la DI avec [ngComponentOutletInjector] quand une même classe tourne dans des contextes différents.
  6. Dès qu'il te faut un output, une méthode ou une ref, quitte la directive pour ViewContainerRef.createComponent. ngComponentOutlet, c'est du descendant only.

ngComponentOutlet, c'est la directive qui transforme un @switch qui grossit sans fin en une table de mapping ouverte à l'extension. Le layout arrête de connaître chaque composant de l'app, et tu ajoutes des variantes sans jamais rouvrir le composant hôte. Sa seule vraie limite, les outputs, te dit exactement quand passer à l'impératif.

Si tu hésites entre projeter un composant (ngComponentOutlet) et projeter un template (ngTemplateOutlet), c'est le sujet d'un article dédié : les deux se ressemblent, mais ne répondent pas au même besoin.

Si tu veux approfondir Angular moderne avec ce genre de patterns, jette un oeil à EasyAngularKit.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.