Angular 15 hat 2022 die Directive Composition API eingeführt; in Angular 21 ist sie selbstverständlicher Bestandteil moderner Komponenten-Bauweise. Die Idee: Statt Verhalten per Klassen-Vererbung zu teilen, werden mehrere standalone Direktiven über hostDirectives an das Host-Element angeheftet. Das Ergebnis ist Komposition wie Mixins – ohne die Single-Inheritance-Limitation von TypeScript-Klassen, mit klarer Sichtbarkeitskontrolle für Inputs und Outputs und mit gemeinsamer Dependency Injection. Dieser Artikel zeigt die API von Grund auf: Grundsyntax, Inputs/Outputs durchreichen mit Renaming, drei Praxis-Beispiele, der Vergleich gegen Vererbung, das DI-Verhalten und die wichtigen Limitations.
Was ist die Directive Composition API?
Die Directive Composition API erlaubt einer Component oder Direktive, andere standalone Direktiven an ihr Host-Element anzuheften – über das Property hostDirectives im Decorator. Die angehefteten Direktiven verhalten sich, als wären sie im Template extern angegeben: Sie sehen das Host-Element, lesen Inputs, feuern Outputs, hängen sich in Lifecycle-Hooks ein und teilen sich den Injector der Host-Component.
Konzeptionell ist das Komposition statt Vererbung: Eine Component erbt nicht das Verhalten einer Basisklasse, sondern setzt sich aus einem Bündel kleiner, einzeln getesteter Direktiven zusammen. Im Resultat sind Komponenten kürzer, Direktiven wiederverwendbarer und Verhalten austauschbar.
Klassisches Problem: Direktiven-Mehrfachverwendung
Vor der Composition API musste jeder Konsument einer Verhaltens-Direktive sie im Template an jedes betroffene Element anhängen:
<!-- Jede Stelle wiederholt die Direktiv-Liste -->
<button appHighlight appFocusRing appAnalytics="cta-1">
Kaufen
</button>
<button appHighlight appFocusRing appAnalytics="cta-2">
Abonnieren
</button>
<button appHighlight appFocusRing appAnalytics="cta-3">
Mehr erfahren
</button>Das ist nicht nur Wiederholung, sondern auch Fehlerquelle: Eine vergessene Direktive an einer einzelnen Stelle bricht das Verhalten still. Die Composition API faltet diese drei Direktiven in eine eigene <app-button>-Component – und an den Verwender-Templates steht nur noch der Component-Tag.
Grundsyntax
hostDirectives ist ein Array. Einträge können entweder ein Direktiv-Klassentyp sein (kürzeste Form, alle Inputs/Outputs bleiben intern) oder ein Konfigurationsobjekt mit expliziten inputs und outputs.
import { Component } from '@angular/core';
import { HighlightDirective } from './highlight.directive';
import { FocusRingDirective } from './focus-ring.directive';
@Component({
selector: 'app-card',
standalone: true,
template: `<ng-content />`,
hostDirectives: [HighlightDirective, FocusRingDirective]
})
export class CardComponent {}Beide Direktiven sind ab sofort am Host-Element der app-card-Component aktiv. Sie wurden weder im Template angegeben, noch muss der Verwender sie kennen oder importieren. Einzige Voraussetzung: Beide sind als standalone: true deklariert (in Angular 21 ohnehin der Default).
Inputs und Outputs durchreichen
Standardmäßig sind Inputs und Outputs der gehosteten Direktiven nicht von außen sichtbar. Das ist Absicht: Eine Direktive kann interne State-Inputs haben, die du gerade NICHT als Component-API exposen willst. Wer einen Input/Output durchreichen möchte, listet ihn explizit – mit optionalem Renaming via originalName: aliasName.
import { Component } from '@angular/core';
import { TooltipDirective } from './tooltip.directive';
import { LoadingDirective } from './loading.directive';
@Component({
selector: 'app-button',
standalone: true,
template: `
<button>
<ng-content />
</button>
`,
hostDirectives: [
{
directive: TooltipDirective,
inputs: ['tooltipText: hint'],
outputs: ['tooltipShown: hintShown']
},
{
directive: LoadingDirective,
inputs: ['loading']
}
]
})
export class ButtonComponent {}Verwender bindet jetzt mit den Aliasen – hint, hintShown – statt mit den Original-Property-Namen:
<app-button
hint="Speichert in der Cloud"
(hintShown)="onHintShown()"
[loading]="isSaving()">
Speichern
</app-button>Das ist mehr als Kosmetik: Die Aliase sind Teil der Public API der Component. Wenn die zugrundeliegende TooltipDirective ihre Inputs intern umbenennt, bleibt die Component-API nach außen stabil – ein wichtiger Hebel für Refactorings.
Praxis: Button mit Tooltip und Loading-State
Drei kleine, fokussierte Direktiven – jede mit einer klaren Aufgabe – ergeben über hostDirectives eine vollwertige Button-Component. Der Vorteil zeigt sich beim Lesen: Die Component ist weniger als zehn Zeilen lang, ihre Logik ist auf drei wiederverwendbare Bausteine verteilt.
import {
Directive,
ElementRef,
HostListener,
inject,
input,
output
} from '@angular/core';
@Directive({
selector: '[appTooltip]',
standalone: true
})
export class TooltipDirective {
private readonly host = inject(ElementRef<HTMLElement>);
readonly tooltipText = input.required<string>();
readonly tooltipShown = output<void>();
@HostListener('mouseenter') show() {
this.host.nativeElement.title = this.tooltipText();
this.tooltipShown.emit();
}
}import {
Directive,
ElementRef,
effect,
inject,
input
} from '@angular/core';
@Directive({
selector: '[appLoading]',
standalone: true,
host: {
'[attr.aria-busy]': 'loading()',
'[class.is-loading]': 'loading()'
}
})
export class LoadingDirective {
private readonly host = inject(ElementRef<HTMLElement>);
readonly loading = input(false);
constructor() {
effect(() => {
this.host.nativeElement.toggleAttribute(
'disabled',
this.loading()
);
});
}
}Die Component aus Abschnitt 04 hängt beide Direktiven ans Host-Element und reicht ihre relevanten Inputs/Outputs weiter. Der Verwender sieht eine kompakte API – hint, hintShown, loading – und braucht von Tooltip und Loading-State nichts zu wissen.
DI: hostDirectives nutzen denselben Injector
Die gehosteten Direktiven teilen den Injector der Host-Component. Konkret heißt das: Ein Service, den die Component injiziert, ist dieselbe Instanz, die auch eine hostDirective injiziert. Beide können zudem aufeinander injizieren.
import {
Component,
Directive,
Injectable,
inject,
signal
} from '@angular/core';
@Injectable()
export class CounterState {
readonly value = signal(0);
}
@Directive({
selector: '[appIncrement]',
standalone: true,
host: { '(click)': 'inc()' }
})
export class IncrementDirective {
private readonly state = inject(CounterState);
inc() { this.state.value.update(v => v + 1); }
}
@Component({
selector: 'app-counter',
standalone: true,
providers: [CounterState],
hostDirectives: [IncrementDirective],
template: `<button>Klicks: {{ state.value() }}</button>`
})
export class CounterComponent {
// Gleiche Instanz wie in IncrementDirective
readonly state = inject(CounterState);
}Da CounterState von der Component provided wird, bekommen beide – Component und gehostete Direktive – dieselbe Signal-Instanz. Ein Klick auf den Button löst die Direktive aus, die das Signal inkrementiert; die Component liest dasselbe Signal direkt im Template.
Vergleich zu Vererbung
Der frühere Standard war, gemeinsames Verhalten in eine Basisklasse zu packen und sie zu erben:
// KLASSISCH: Vererbung
abstract class HighlightableBase {
// Logik mit ElementRef, HostListener etc.
}
@Component({ /* ... */ })
export class CardComponent extends HighlightableBase {
// Erbt Highlight-Verhalten – aber kann nichts Anderes erben
}
// MODERN: Composition
@Component({
// ...
hostDirectives: [
HighlightDirective,
FocusRingDirective,
TrackingDirective
]
})
export class CardComponent {
// Beliebig viele Verhalten kombinierbar
}Die Vererbung hat zwei harte Limitationen: TypeScript erlaubt nur eine Basisklasse, und das Verhalten der Basisklasse ist tief mit der Subklasse verwoben (kein einfaches „abklemmen”). Composition löst beides – beliebig viele Direktiven, jede einzeln testbar, jede einzeln zu- und abschaltbar. Für Angular-eigenes Verhalten ist Composition seit v15 der empfohlene Default.
Praxis: Mehrere Direktiven kombinieren
Drei klar getrennte Verantwortlichkeiten in einer Component – sauber komponiert:
import { Component } from '@angular/core';
import { HighlightDirective } from './highlight.directive';
import { TooltipDirective } from './tooltip.directive';
import { AnalyticsDirective } from './analytics.directive';
@Component({
selector: 'app-cta-card',
standalone: true,
template: `
<div class="cta">
<ng-content />
</div>
`,
hostDirectives: [
HighlightDirective,
{
directive: TooltipDirective,
inputs: ['tooltipText: hint']
},
{
directive: AnalyticsDirective,
inputs: ['analyticsId: trackingId'],
outputs: ['analyticsFired: tracked']
}
]
})
export class CtaCardComponent {}<app-cta-card
hint="Sofort starten"
trackingId="cta_signup"
(tracked)="logTracked()">
Jetzt registrieren
</app-cta-card>Drei Direktiven, eine Component-Schnittstelle, klarer Public-API-Vertrag. Der Verwender muss weder die einzelnen Direktiven importieren noch ihre internen Property-Namen kennen.
Limitations und Stolperfallen
Die Composition API ist mächtig, aber nicht beliebig. Drei harte Grenzen sind wichtig zu kennen:
| Mechanismus | Geht? | Bemerkung |
|---|---|---|
Standalone-Direktive in hostDirectives | Ja | Default seit v15 |
| NgModule-deklarierte Direktive | Nein | Build-Error – muss als standalone konvertiert werden |
| Bedingtes Anhängen zur Laufzeit | Nein | hostDirectives ist statisch |
Strukturdirektive (mit *) als hostDirective | Nein | Composition gilt nur für Attribute-Direktiven |
| Component als hostDirective | Nein | Nur @Directive-dekorierte Klassen |
| Selector der gehosteten Direktive nutzen | Nein | Wird ignoriert, der Selector der Host-Component zählt |
| Provider der gehosteten Direktive | Ja | Werden in den gemeinsamen Injector eingebracht |
| Input/Output durchreichen ohne Renaming | Ja | inputs: ['loading'] |
| Input/Output mit Renaming | Ja | inputs: ['loading: busy'] |
Die wichtigste Regel: Composition ist für Verhalten am Host-Element, nicht für DOM-Strukturen. Wer Inhalte einfügen, entfernen oder vervielfältigen will, braucht weiterhin Strukturdirektiven oder Built-in Control Flow.
Wissenswertes zur Composition API
hostDirectives müssen standalone sein
Eine NgModule-deklarierte Direktive in hostDirectives bricht den Build mit einer expliziten Fehlermeldung. Die Migration ist trivial: standalone: true setzen und sie aus dem NgModule entfernen. In Angular 21 ist standalone der Default; nur Legacy-Code aus Pre-v15-Zeiten braucht hier Aufräumarbeit.
Renaming ändert die Public API der Component
inputs: [‘tooltipText: hint’] exposed das Property als hint – nicht als tooltipText. Verwender bindet [hint]=”…”. Das ist ein bewusster Hebel: Refactorings an der internen Direktive bleiben unsichtbar, solange das Mapping stabil bleibt. Saubere Public APIs entstehen genau hier.
Geteilter Injector – ein Service, eine Instanz
Ein Service, den die Component provided, ist die gleiche Instanz, die auch jede gehostete Direktive injiziert. Gut für gemeinsamen State. Gefährlich, wenn versehentlich zwei Direktiven dieselbe Instanz mutieren – dann ist die Reihenfolge der Lifecycle-Hooks plötzlich relevant.
hostDirectives + host-Bindings ergänzen sich
Eine gehostete Direktive kann eigene host: {‘[class.foo]’: ’…’}-Bindings haben. Diese wirken auf das Host-Element der konsumierenden Component. Bei Konflikten (gleiches Property zweimal gebunden) gilt: Component-eigene Host-Bindings gewinnen über Direktiv-Host-Bindings, gehostete Direktiven binden vor der Component.
Composition NICHT für Strukturdirektiven
Strukturen-Manipulation (mit Microsyntax-Asterisk) lässt sich nicht über hostDirectives komponieren. Eine *appPerm-Direktive an die Host-Component zu hängen ergibt keinen Sinn – die Microsyntax wickelt das Element in ein <ng-template>, das es bei der Host-Component nicht gibt. Strukturdirektiven bleiben Template-Direktiven.
Reihenfolge der Direktiven ist deterministisch
Die Liste in hostDirectives hat eine feste Auswertungsreihenfolge: Erste in der Liste wird zuerst instantiiert, ihre Lifecycle-Hooks laufen zuerst, ihre Host-Bindings werden zuerst angewendet. Bei zwei Direktiven, die dieselbe Klasse über ‘[class.x]’ binden, gewinnt die spätere – „last wins” für Host-Bindings.
Beliebig viele Direktiven – keine Single-Inheritance-Limitation
Im Gegensatz zur Klassen-Vererbung (eine Basisklasse, ein Verhalten) lassen sich beliebig viele Direktiven kombinieren. Eine Component mit fünf hostDirectives ist legitim und wird in Standardbibliotheken (Angular Material, CDK) regelmäßig genutzt. Der Verbund bleibt verständlich, weil jede Direktive ihre Verantwortung klar gekapselt hat.
Built-in Control Flow im Component-Template bleibt unangetastet
@if, @for, @switch im Template einer Component, die hostDirectives nutzt, funktionieren genauso wie sonst. Composition wirkt ausschließlich auf das Host-Element – das Template-Innere kennt davon nichts. Beide Mechanismen sind orthogonal kombinierbar.
Weiterführende Ressourcen
Externe Quellen
- Directive Composition API – Angular.dev
- Directive Decorator (
hostDirectives) – Angular.dev - Standalone Components – Angular.dev