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:

HTML without-composition.html
<!-- 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.

TypeScript card.component.ts
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.

TypeScript button.component.ts
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 Aliasenhint, hintShown – statt mit den Original-Property-Namen:

HTML button-usage.html
<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.

TypeScript tooltip.directive.ts
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();
    }
}
TypeScript loading.directive.ts
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.

TypeScript shared-di.ts
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:

TypeScript inheritance-vs-composition.ts
// 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:

TypeScript cta-card.component.ts
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 {}
HTML cta-card-usage.html
<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:

MechanismusGeht?Bemerkung
Standalone-Direktive in hostDirectivesJaDefault seit v15
NgModule-deklarierte DirektiveNeinBuild-Error – muss als standalone konvertiert werden
Bedingtes Anhängen zur LaufzeitNeinhostDirectives ist statisch
Strukturdirektive (mit *) als hostDirectiveNeinComposition gilt nur für Attribute-Direktiven
Component als hostDirectiveNeinNur @Directive-dekorierte Klassen
Selector der gehosteten Direktive nutzenNeinWird ignoriert, der Selector der Host-Component zählt
Provider der gehosteten DirektiveJaWerden in den gemeinsamen Injector eingebracht
Input/Output durchreichen ohne RenamingJainputs: ['loading']
Input/Output mit RenamingJainputs: ['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

/ Weiter

Zurück zu Directives

Zur Übersicht