Dynamic Components sind Angular-Komponenten, die zur Laufzeit erzeugt werden statt im Template fest verdrahtet zu sein. Du kennst den konkreten Komponententyp erst, wenn der Nutzer auf einen Button klickt, ein Schema vom Server kommt oder ein Plugin geladen wird — und genau dafür liefert Angular drei aufeinander abgestimmte APIs: das deklarative NgComponentOutlet, den imperativen ViewContainerRef.createComponent() und das low-level createComponent() aus @angular/core.
Dieser Artikel zeigt dir mit Angular 21 (aktueller Stand), wie du Modals, Toasts, polymorphe Form-Renderer und Plugin-Slots baust, wie ComponentRef Inputs typsicher per setInput() aktualisiert, wann ApplicationRef.attachView zwingend ist und wo Cleanup-Fallen lauern.
Komponenten ohne Template-Verdrahtung
Im Standardfall stehen Angular-Komponenten statisch im Template: <app-card />, <app-modal />, <app-button />. Der Compiler weiß zur Build-Zeit, welche Selektoren benutzt werden, und kann optimieren. Eine Dynamic Component kehrt das um — der Komponententyp ist eine Variable, die zur Laufzeit gesetzt wird.
Typische Use-Cases:
- Modals und Dialogs — ein
ModalService.open(MyEditComponent, data)öffnet von überall im Code ein Overlay, ohne dass das aufrufende Template den Dialog kennen muss. - Toasts und Notifications — die Notification-Component existiert nirgends im Template, sondern wird beim ersten
notify(...)als Detached View attached. - Plugin-Systeme — Erweiterungen registrieren ihren Renderer beim Host und werden über einen Slot eingebettet.
- Polymorphe Listen — eine Activity-Feed-Liste rendert pro Item je nach Typ
MessageItem,LikeItemoderFollowItem, alle aus derselben@for-Schleife. - Form-Field-Renderer — ein JSON-Schema beschreibt Felder, jedes Feld mappt auf eine eigene Component (Text, Select, Datepicker, …).
NgComponentOutlet, ViewContainerRef, createComponent
Angular bietet drei APIs für Dynamic Components — vom deklarativen Einzeiler bis zum vollständig manuellen Setup. Welche du wählst, hängt davon ab, wie viel Kontrolle du brauchst und wo die Component im DOM landen soll.
| API | Stil | Kontrolle | Typischer Use-Case |
|---|---|---|---|
*ngComponentOutlet | deklarativ | gering | Polymorphe Listen, Schema-Renderer, Card-Variante |
ViewContainerRef.createComponent() | imperativ | hoch | Tabs, Wizard-Schritte, Tooltip-Anker im Tree |
createComponent() aus core | low-level | maximal | Modals, Toasts, Portals, Components am <body> |
Faustregel: Beginne mit NgComponentOutlet. Es deckt rund 80 % aller Use-Cases mit einer Zeile Template-Code. Wechsle erst zu ViewContainerRef, wenn du den ComponentRef festhalten musst — und zu createComponent() aus @angular/core erst dann, wenn die Component außerhalb des Component-Trees leben muss.
Deklarativ aus CommonModule
NgComponentOutlet ist eine strukturelle Direktive aus @angular/common. Du gibst ihr einen Komponententyp, optional Inputs, Outputs, Injector und Content — und Angular kümmert sich um Erzeugung, Update und Cleanup.
import { Component, signal, Type } from '@angular/core';
import { NgComponentOutlet } from '@angular/common';
@Component({
selector: 'app-banner-card',
standalone: true,
template: `<div class="banner">{{ title() }}</div>`,
})
export class BannerCardComponent {
title = signal('Banner');
}
@Component({
selector: 'app-image-card',
standalone: true,
template: `<figure><img [src]="src()" alt="" /></figure>`,
})
export class ImageCardComponent {
src = signal('/img/cover.webp');
}
@Component({
selector: 'app-card-switcher',
standalone: true,
imports: [NgComponentOutlet],
template: `
<button (click)="toggle()">Variante wechseln</button>
<ng-container
*ngComponentOutlet="
current();
inputs: { title: 'Hallo Welt', src: '/img/x.webp' }
"
/>
`,
})
export class CardSwitcherComponent {
current = signal<Type<unknown>>(BannerCardComponent);
toggle() {
this.current.set(
this.current() === BannerCardComponent
? ImageCardComponent
: BannerCardComponent
);
}
}Die Inputs werden über ngComponentOutletInputs als einfaches Record-Objekt übergeben. Angular ruft intern componentRef.setInput() auf — das ist äquivalent zum Setzen von [title]="..." im statischen Template, inklusive OnChanges-Hook und OnPush-Trigger. Ändert sich das Inputs-Objekt, propagiert die Direktive die Änderungen automatisch.
Anker im Component-Tree
Ein ViewContainer ist ein logischer Anker im Component-Tree, an den du Host-Views (Components) oder Embedded-Views (TemplateRef-Instanzen) hängen kannst. Du bekommst einen ViewContainerRef über inject() in einer Component oder Directive — er zeigt auf die Position des Hosts.
import {
Component,
ViewContainerRef,
inject,
ComponentRef,
Type,
signal,
} from '@angular/core';
@Component({
selector: 'app-greeting',
standalone: true,
template: `<p>Hallo, {{ name() }}!</p>`,
})
export class GreetingComponent {
name = signal('Welt');
}
@Component({
selector: 'app-dynamic-host',
standalone: true,
template: `
<button (click)="mount()">Mount</button>
<button (click)="updateName()" [disabled]="!ref">Name ändern</button>
<button (click)="unmount()" [disabled]="!ref">Unmount</button>
`,
})
export class DynamicHostComponent {
private vcr = inject(ViewContainerRef);
ref: ComponentRef<GreetingComponent> | null = null;
mount() {
this.ref?.destroy();
this.ref = this.vcr.createComponent(GreetingComponent);
this.ref.setInput('name', 'Anna');
}
updateName() {
this.ref?.setInput('name', 'Bernd');
}
unmount() {
this.ref?.destroy();
this.ref = null;
}
}Der wichtige Punkt: vcr.createComponent() fügt die neue Component als Geschwister des Host-Elements ein, nicht als Kind. Wenn du Inhalt innerhalb eines bestimmten DOM-Knotens platzieren willst, deklarier dir ein <ng-container #anchor></ng-container> und hole dir per viewChild('anchor', { read: ViewContainerRef }) einen ViewContainer an genau dieser Stelle.
Inputs setzen, Outputs lauschen, destroyen
createComponent() (egal ob auf ViewContainerRef oder als freie Funktion) gibt dir einen ComponentRef<T> zurück. Das ist dein Handle für alles, was die dynamische Instanz betrifft.
import {
Component,
ViewContainerRef,
inject,
output,
input,
} from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<p>{{ label() }}: {{ count }}</p>
<button (click)="inc()">+</button>
`,
})
export class CounterComponent {
label = input<string>('Zähler');
changed = output<number>();
count = 0;
inc() {
this.count += 1;
this.changed.emit(this.count);
}
}
@Component({
selector: 'app-stage',
standalone: true,
template: `<button (click)="open()">Counter starten</button>`,
})
export class StageComponent {
private vcr = inject(ViewContainerRef);
open() {
const ref = this.vcr.createComponent(CounterComponent);
// 1. Typsicheres setInput (triggert OnPush + ngOnChanges)
ref.setInput('label', 'Klicks');
// 2. Output abonnieren
const sub = ref.instance.changed.subscribe((n) => {
if (n >= 5) ref.destroy();
});
// 3. Cleanup beim destroy
ref.onDestroy(() => sub.unsubscribe());
// 4. DOM-Zugriff über location
ref.location.nativeElement.classList.add('is-dynamic');
}
}ComponentRef ist die zentrale Schnittstelle:
instance— die echte Component-Instanz, mit allen Methoden, Signals, Outputs.setInput(name, value)— typsicher (Compile-Zeit-Check für Signal-Inputs); ruftngOnChangesund triggert OnPush.location.nativeElement— der DOM-Knoten, an dem die Component hängt.hostView— derViewRef, den du anApplicationRef.attachView()übergeben kannst.changeDetectorRef— manuellesmarkForCheck()oderdetectChanges().destroy()— entfernt die Component, ruftngOnDestroyund alleonDestroy-Callbacks.onDestroy(fn)— registriert eine Aufräum-Funktion.
Detached Components mit @angular/core
Die freie Funktion createComponent() aus @angular/core ist die low-level Variante. Sie braucht keinen ViewContainerRef, sondern bekommt einen EnvironmentInjector und optional ein hostElement direkt im DOM. Damit kannst du Components erzeugen, die außerhalb des Component-Trees leben — z. B. direkt am document.body für Portals und Modals.
import {
createComponent,
ApplicationRef,
EnvironmentInjector,
inject,
Injectable,
Type,
ComponentRef,
} from '@angular/core';
@Injectable({ providedIn: 'root' })
export class PortalService {
private envInjector = inject(EnvironmentInjector);
private appRef = inject(ApplicationRef);
attach<T>(component: Type<T>): ComponentRef<T> {
const host = document.createElement('div');
host.classList.add('portal-host');
document.body.appendChild(host);
const ref = createComponent(component, {
environmentInjector: this.envInjector,
hostElement: host,
});
// OHNE diesen Schritt läuft KEINE Change Detection!
this.appRef.attachView(ref.hostView);
ref.onDestroy(() => {
this.appRef.detachView(ref.hostView);
host.remove();
});
return ref;
}
}Drei Dinge sind hier essenziell:
environmentInjectorist Pflicht — er macht die globalen Provider (Router, HttpClient, …) für die dynamische Component verfügbar.hostElementist optional. Gibst du keines an, erzeugt Angular einen passenden Host-Knoten, den du selbst im DOM platzieren musst.ApplicationRef.attachView(hostView)ist zwingend, sobald die Component nicht in einem ViewContainer hängt. Sonst ignoriert Change Detection sie komplett.
Vollständiger ModalService
Ein Modal ist der Lehrbuch-Use-Case für createComponent(): Es soll am <body> hängen (Stacking-Context, Backdrop), unabhängig vom Aufruf-Tree leben und mit der Aufruf-Component über Inputs und ein Close-Signal kommunizieren.
import {
createComponent,
ApplicationRef,
EnvironmentInjector,
inject,
Injectable,
Type,
ComponentRef,
signal,
} from '@angular/core';
export interface ModalRef<T = unknown> {
instance: T;
close(): void;
afterClosed: Promise<void>;
}
@Injectable({ providedIn: 'root' })
export class ModalService {
private envInjector = inject(EnvironmentInjector);
private appRef = inject(ApplicationRef);
private openRefs = signal<ComponentRef<unknown>[]>([]);
open<T>(
component: Type<T>,
inputs: Partial<Record<keyof T, unknown>> = {}
): ModalRef<T> {
const host = document.createElement('div');
host.classList.add('modal-host');
document.body.appendChild(host);
const ref = createComponent(component, {
environmentInjector: this.envInjector,
hostElement: host,
});
for (const [k, v] of Object.entries(inputs)) {
ref.setInput(k, v);
}
this.appRef.attachView(ref.hostView);
this.openRefs.update((rs) => [...rs, ref]);
let resolveClose!: () => void;
const afterClosed = new Promise<void>((r) => (resolveClose = r));
const close = () => {
ref.destroy();
this.appRef.detachView(ref.hostView);
host.remove();
this.openRefs.update((rs) => rs.filter((r) => r !== ref));
resolveClose();
};
return { instance: ref.instance, close, afterClosed };
}
closeAll() {
this.openRefs().forEach((r) => r.destroy());
this.openRefs.set([]);
}
}Die Modal-Component selbst ist eine ganz normale Standalone-Component — sie weiß gar nicht, dass sie dynamisch instanziiert wurde:
import { Component, input, output } from '@angular/core';
@Component({
selector: 'app-confirm-dialog',
standalone: true,
template: `
<div class="backdrop" (click)="cancelled.emit()"></div>
<div class="dialog">
<h2>{{ title() }}</h2>
<p>{{ message() }}</p>
<button (click)="cancelled.emit()">Abbrechen</button>
<button (click)="confirmed.emit()">OK</button>
</div>
`,
})
export class ConfirmDialogComponent {
title = input.required<string>();
message = input.required<string>();
confirmed = output<void>();
cancelled = output<void>();
}
// Aufruf an beliebiger Stelle:
// const ref = modal.open(ConfirmDialogComponent, {
// title: 'Wirklich löschen?',
// message: 'Dieser Schritt ist nicht umkehrbar.',
// });
// ref.instance.confirmed.subscribe(() => ref.close());
// ref.instance.cancelled.subscribe(() => ref.close());Schema-getriebenes Form aus @for und NgComponentOutlet
Polymorphe UIs sind das Heimspiel von NgComponentOutlet. Statt jeden Feldtyp im Template hart zu verdrahten, beschreibst du das Form als Daten-Schema und mappst Typen auf Components.
import { Component, signal, Type, input, output } from '@angular/core';
import { NgComponentOutlet } from '@angular/common';
// 1. Feld-Components — alle teilen ein gemeinsames Input/Output-Schema
@Component({
selector: 'app-text-field',
standalone: true,
template: `
<label>
{{ label() }}
<input
[value]="value()"
(input)="changed.emit(($any($event.target)).value)"
/>
</label>
`,
})
export class TextFieldComponent {
label = input.required<string>();
value = input<string>('');
changed = output<string>();
}
@Component({
selector: 'app-checkbox-field',
standalone: true,
template: `
<label>
<input
type="checkbox"
[checked]="value()"
(change)="changed.emit(($any($event.target)).checked)"
/>
{{ label() }}
</label>
`,
})
export class CheckboxFieldComponent {
label = input.required<string>();
value = input<boolean>(false);
changed = output<boolean>();
}
// 2. Schema und Mapping
type FieldSchema =
| { kind: 'text'; name: string; label: string; value: string }
| { kind: 'checkbox'; name: string; label: string; value: boolean };
const FIELD_REGISTRY: Record<FieldSchema['kind'], Type<unknown>> = {
text: TextFieldComponent,
checkbox: CheckboxFieldComponent,
};
// 3. Renderer-Component
@Component({
selector: 'app-form-renderer',
standalone: true,
imports: [NgComponentOutlet],
template: `
<form>
@for (field of schema(); track field.name) {
<ng-container
*ngComponentOutlet="
componentFor(field);
inputs: { label: field.label, value: field.value }
"
/>
}
</form>
`,
})
export class FormRendererComponent {
schema = signal<FieldSchema[]>([
{ kind: 'text', name: 'firstName', label: 'Vorname', value: '' },
{ kind: 'text', name: 'lastName', label: 'Nachname', value: '' },
{ kind: 'checkbox', name: 'newsletter', label: 'Newsletter', value: true },
]);
componentFor(f: FieldSchema): Type<unknown> {
return FIELD_REGISTRY[f.kind];
}
}Neue Feldtypen sind eine neue Component plus ein Eintrag im FIELD_REGISTRY — kein Switch im Template, kein @if-Wald, keine doppelte Wartung.
Bundle-Splitting jenseits von Routen
loadComponent in der Route-Config ist die bekannte Variante. Du kannst dasselbe Pattern aber auch ohne Router nutzen, um einzelne Components erst dann ins Bundle zu ziehen, wenn der Nutzer wirklich einen Button klickt.
import {
Component,
inject,
ViewContainerRef,
signal,
} from '@angular/core';
@Component({
selector: 'app-lazy-host',
standalone: true,
template: `
<button (click)="openHeavy()" [disabled]="loading()">
{{ loading() ? 'Lädt …' : 'Schwere Component öffnen' }}
</button>
`,
})
export class LazyHostComponent {
private vcr = inject(ViewContainerRef);
loading = signal(false);
async openHeavy() {
this.loading.set(true);
try {
const { HeavyChartComponent } = await import(
'./heavy-chart.component'
);
const ref = this.vcr.createComponent(HeavyChartComponent);
ref.setInput('series', [/* … */]);
} finally {
this.loading.set(false);
}
}
}Der dynamische import() löst beim Build ein eigenes Chunk aus. Das initiale Bundle bleibt klein, schwere Abhängigkeiten (Charts, Editoren, PDF-Renderer) werden erst beim Klick nachgeladen. Kombiniert mit @defer lässt sich das gleiche Muster auch deklarativ ausdrücken — createComponent brauchst du nur, wenn die Lade-Logik außerhalb des Templates lebt (z. B. in einem Service).
Destroy, Tracking, keine Loops
Dynamic Components sind kein Free Lunch. Drei Stolperfallen tauchen immer wieder in Reviews auf:
- Cleanup vergessen. Jede Component, die du selbst erzeugst, musst du auch selbst zerstören.
ref.destroy()ruftngOnDestroyauf der Instance, alleonDestroy()-Callbacks und entfernt das Host-Element. Wenn du eine Subscription außerhalb der Component eröffnet hast (z. B. auf einem Output), häng sie perref.onDestroy(() => sub.unsubscribe())an. - Detached Views ohne
attachView.createComponent()aus@angular/coregibt dir einenhostView, den Change Detection nur sieht, wenn er perApplicationRef.attachView()registriert ist. Vergisst du das, läuft kein einziger CD-Cycle auf der Component — Inputs aktualisieren sich nicht, Effects feuern nicht, Templates bleiben stehen. - Recreation in Schleifen. Erzeuge dynamische Components nicht in jedem Change-Detection-Cycle neu. Wenn eine Liste sich ändert, kann
@formittrackdie Identität der Items über CD-Cycles hinweg halten —NgComponentOutleterzeugt dann nur dann neu, wenn der Komponententyp wirklich wechselt.
Besonderheiten
NgComponentOutlet reicht für 80 Prozent
Die deklarative Direktive aus @angular/common deckt die meisten Fälle ab: Inputs, Outputs (ab v17), Injector, projizierten Content. Greife nur dann zu ViewContainerRef, wenn du den ComponentRef selbst halten musst — etwa um aus Service-Code heraus mehrere Instanzen zu steuern oder Lifecycle exakt zu orchestrieren.
setInput ist typsicher und CD-aware
componentRef.setInput(‘label’, ‘OK’) ist die einzig richtige Art, Inputs an dynamischen Components zu setzen. Direktes instance.label = ‘OK’ funktioniert bei klassischen @Inputs, scheitert aber an Signal-Inputs aus input() — die sind read-only. setInput() triggert ngOnChanges und OnPush-Change-Detection korrekt.
createComponent() ist das Standalone-Pattern
Seit Angular 14 ist die freie createComponent()-Funktion aus @angular/core der idiomatische Weg für detached Components — kein NgModule, kein ComponentFactoryResolver mehr. Letzterer ist seit v13 deprecated und in v17 entfernt. Wer noch alten Code mit resolveComponentFactory() sieht, sollte ihn migrieren.
ApplicationRef.attachView ist Pflicht
Components, die mit der freien createComponent() erzeugt und nicht in einem ViewContainerRef sitzen (typisch: Modals, Toasts am <body>), nehmen nur dann an Change Detection teil, wenn ihre hostView per appRef.attachView() registriert ist. Vergisst du das, sieht man nur die initiale Render-Phase und nichts mehr danach.
destroy() ruft ngOnDestroy automatisch
componentRef.destroy() kümmert sich um den Lifecycle-Hook der dynamischen Instance. Du brauchst kein zusätzliches Cleanup-Boilerplate für die Component selbst — wohl aber für externe Subscriptions, die du an die Instance gehängt hast. Dafür gibt es ref.onDestroy(() => sub.unsubscribe()).
Outputs in NgComponentOutlet erst ab v17
Bis Angular 16.x konnte NgComponentOutlet nur Inputs binden. Wenn du in einem Codebase noch auf componentRef.instance.myOutput.subscribe() stößt, ist das Legacy aus dieser Zeit. Ab v17 schreibst du Outputs direkt in der Direktiven-Syntax, identisch zur statischen Template-Bindung.
ChangeDetectorRef bei detached Views manuell
Wenn du eine dynamische Component aus dem Standard-CD-Cycle nimmst (etwa per changeDetectorRef.detach() für Performance-kritische Listen), bist du selbst dafür verantwortlich, mit detectChanges() oder markForCheck() Updates auszulösen. Das ist mächtig, aber auch eine der häufigsten Quellen für „mein Template aktualisiert sich nicht”-Bugs.
Pool-Pattern statt Recreation pro Frame
Bei sehr vielen, häufig wechselnden Components (10000+ Items, virtuelles Scrolling) ist Erzeugen und Zerstören pro CD-Cycle ein Performance-Killer. @for mit track hält die Identität über Cycles. Reicht das nicht, halte einen kleinen Pool fertiger Instanzen vor und tausche per setInput() nur die Daten — ein bekanntes Recyclerview-Pattern aus der mobilen Welt, das auch in Angular hilft.
Weiterführende Ressourcen
Externe Quellen
- Anatomy of Components – Angular.dev
- ViewContainerRef API – Angular.dev
- NgComponentOutlet API – Angular.dev
- ComponentRef API – Angular.dev
- createComponent() API – Angular.dev