Seit Angular 17 deckt der Built-in Control Flow (@if, @for, @switch) den allergrößten Teil der ehemals von *ngIf und *ngFor bedienten Use-Cases ab – kompakter, type-narrowed, Compiler-optimiert. Trotzdem bleibt das Konzept der eigenen Strukturdirektive in Angular 21 ein scharfes Werkzeug. Sobald die Render-Bedingung von externer Logik abhängt (Permissions, Feature-Flags, Lade-Slots), oder ein Template mehrere <ng-template>-Branches mit Context-Object koordinieren muss, ist eine eigene Direktive sauberer als ein Bündel ineinander verschachtelter @if-Blöcke. Dieser Artikel zeigt das Werkzeug von Grund auf: Microsyntax-Desugaring, der Bauplan einer Direktive, drei vollständige Praxis-Direktiven (appUnless, appPermission, appRepeat) und die klare Entscheidungsmatrix gegen den Built-in Control Flow.
Was ist eine Strukturdirektive?
Eine Strukturdirektive verändert die DOM-Struktur ihres Host-Elements: Sie entscheidet, ob und wie oft das Element im DOM landet. Anders als Attribute-Direktiven, die ein bestehendes Element nur dekorieren (Klassen, Styles, Events), wickeln Strukturdirektiven ihr Host-Element implizit in ein <ng-template> und entscheiden dann selbst, wann sie aus diesem Template eine View instantiieren.
Erkennbar am Asterisk-Präfix: *ngIf, *ngFor, *ngSwitchCase. Der Stern ist Compiler-Sugar für genau dieses Wrapping. Die Direktive bekommt das eingewickelte Template über TemplateRef injiziert und steuert über ViewContainerRef, ob daraus eine eingebettete View erzeugt wird.
Wann lohnt sich eine eigene Strukturdirektive?
Drei Fragen entscheiden das ehrlich:
- Wiederverwendbarkeit über Komponenten und Apps hinweg? Ein
*appPermission="'admin'"taucht in zehn Komponenten auf – eine Direktive ist die richtige Abstraktion. Ein einmaliges@if (user.role === 'admin') { ... }reicht hingegen ohne Direktive. - Mehrere Template-Slots mit Context? Eine Direktive, die – wie
ngIf– einthenund einelsemit Loading-Branch koordiniert, ist mit@ifzwar machbar, aber als eigenständiger Baustein lesbarer. - Eine eigene Iteration mit Domänen-Logik? Wenn die Schleife über eine API iteriert (Pagination-Cursor, Server-Stream, eine Permission-gefilterte Liste), kapselt eine Direktive das sauberer als wiederholtes Filter-Pipe-Boilerplate.
Trifft keiner dieser drei Punkte zu, schreib direkt @if/@for. Die meisten App-internen Bedingungen sind weder wiederverwendbar noch komplex – Built-in Control Flow ist dann immer schneller, kürzer und vom Compiler besser optimiert.
Aufbau einer Strukturdirektive
Drei Bausteine reichen: Selector in eckigen Klammern, TemplateRef und ViewContainerRef per inject(), ein @Input mit Setter (oder ein Signal-Input + effect), der bei Änderung die View entweder via createEmbeddedView() rendert oder via clear() entfernt.
import {
Directive,
Input,
TemplateRef,
ViewContainerRef,
inject
} from '@angular/core';
@Directive({
selector: '[appSkeleton]',
standalone: true
})
export class SkeletonDirective {
private readonly tpl = inject(TemplateRef<unknown>);
private readonly vcr = inject(ViewContainerRef);
@Input() set appSkeleton(condition: boolean) {
this.vcr.clear();
if (condition) {
this.vcr.createEmbeddedView(this.tpl);
}
}
}Dieser Mini-Bauplan ist die Vorlage für jede eigene Strukturdirektive. Der Selector muss in [] stehen – sonst hält der Compiler ihn für einen Komponenten-Selektor. Der @Input-Name muss exakt dem Selector entsprechen, weil die Microsyntax *appSkeleton="cond" daraus den Property-Namen ableitet.
Praxis: *appUnless – das invertierte @if
Der Klassiker: rendert nur, wenn die Bedingung falsy ist. Die Implementierung trackt einen hasView-Flag, um doppeltes Rendern zu verhindern.
import {
Directive,
Input,
TemplateRef,
ViewContainerRef,
inject
} from '@angular/core';
@Directive({
selector: '[appUnless]',
standalone: true
})
export class UnlessDirective {
private readonly tpl = inject(TemplateRef<unknown>);
private readonly vcr = inject(ViewContainerRef);
private hasView = false;
@Input() set appUnless(condition: boolean) {
if (!condition && !this.hasView) {
this.vcr.createEmbeddedView(this.tpl);
this.hasView = true;
} else if (condition && this.hasView) {
this.vcr.clear();
this.hasView = false;
}
}
}Verwendung im Template – exakt analog zu *ngIf, nur invertiert:
<p *appUnless="isLoggedIn()">
Bitte melde dich an, um Inhalte zu sehen.
</p>Der Vergleich zu @if (!isLoggedIn()) { ... } zeigt das Gewicht: Für eine einzelne Stelle ist @if kürzer. Wenn dieselbe „Anti-Bedingung” aber an dreißig Stellen wiederkehrt, ist *appUnless semantisch klarer und lokal beim Lesen sofort verständlich.
Microsyntax verstehen
Der Asterisk ist reine Compiler-Magie. Aus *appPermission="'admin'; else fallback" wird intern:
<!-- Du schreibst: -->
<section *appPermission="'admin'; else fallback">
Admin-Bereich
</section>
<ng-template #fallback>Keine Berechtigung.</ng-template>
<!-- Der Compiler macht daraus: -->
<ng-template
[appPermission]="'admin'"
[appPermissionElse]="fallback">
<section>Admin-Bereich</section>
</ng-template>
<ng-template #fallback>Keine Berechtigung.</ng-template>Zwei Regeln daraus folgen direkt:
- Primärer Input trägt den Direktiv-Namen (
appPermission). - Sekundäre Inputs werden gebildet aus Direktiv-Name + Schlüsselwort, in CamelCase:
else→appPermissionElse. Die Konvention ist hart, sonst greift die Microsyntax-Bindung nicht.
Then- und Else-Templates
Wer eine Direktive baut, die wie ngIf zwei Branches hat (then/else), nimmt zwei zusätzliche TemplateRef-Inputs auf und entscheidet beim Update, welcher der drei Templates gerendert wird (then, else oder das eingewickelte Default).
import {
Directive,
Input,
TemplateRef,
ViewContainerRef,
inject
} from '@angular/core';
@Directive({
selector: '[appBranch]',
standalone: true
})
export class BranchDirective {
private readonly defaultTpl = inject(TemplateRef<unknown>);
private readonly vcr = inject(ViewContainerRef);
private condition = false;
private thenTpl: TemplateRef<unknown> | null = null;
private elseTpl: TemplateRef<unknown> | null = null;
@Input() set appBranch(value: boolean) {
this.condition = value;
this.update();
}
@Input() set appBranchThen(tpl: TemplateRef<unknown> | null) {
this.thenTpl = tpl;
this.update();
}
@Input() set appBranchElse(tpl: TemplateRef<unknown> | null) {
this.elseTpl = tpl;
this.update();
}
private update() {
this.vcr.clear();
const target = this.condition
? (this.thenTpl ?? this.defaultTpl)
: this.elseTpl;
if (target) {
this.vcr.createEmbeddedView(target);
}
}
}Verwendung:
<div *appBranch="isPremium(); else freeTpl">
Premium-Inhalt
</div>
<ng-template #freeTpl>
Kostenloser Vorschau-Inhalt
</ng-template>vcr.clear() vor jedem createEmbeddedView() ist Pflicht – ohne dieses Aufräumen würde bei jeder Property-Änderung eine zusätzliche View entstehen. Klassischer Memory-Leak im DOM.
Praxis: *appPermission mit Service-DI
Die wirklich wertvollen Strukturdirektiven sind nicht solche, die @if invertieren – sondern jene, die wiederverwendbare Domänen-Logik kapseln. Permission-Checks sind das Paradebeispiel: Ein Service (PermissionService) führt eine Liste der aktuellen User-Rollen als Signal, die Direktive konsumiert das Signal mit effect() und rendert nur, wenn die geforderte Rolle vorhanden ist.
import { Injectable, signal } from '@angular/core';
export type Role = 'admin' | 'editor' | 'viewer';
@Injectable({ providedIn: 'root' })
export class PermissionService {
readonly roles = signal<readonly Role[]>(['viewer']);
has(role: Role): boolean {
return this.roles().includes(role);
}
}import {
Directive,
TemplateRef,
ViewContainerRef,
effect,
inject,
input
} from '@angular/core';
import { PermissionService, Role } from './permission.service';
@Directive({
selector: '[appPermission]',
standalone: true
})
export class PermissionDirective {
private readonly tpl = inject(TemplateRef<unknown>);
private readonly vcr = inject(ViewContainerRef);
private readonly perms = inject(PermissionService);
readonly appPermission = input.required<Role>();
constructor() {
effect(() => {
const required = this.appPermission();
const granted = this.perms.roles().includes(required);
this.vcr.clear();
if (granted) {
this.vcr.createEmbeddedView(this.tpl);
}
});
}
}Verwendung:
<button *appPermission="'admin'" (click)="deleteAll()">
Alles löschen
</button>
<a *appPermission="'editor'" routerLink="/edit">
Bearbeiten
</a>Im Vergleich zur Inline-Variante mit @if:
<!-- Inline mit @if (DI im Template-Kontext fehlt → Component muss helfen) -->
@if (perms.has('admin')) {
<button (click)="deleteAll()">Alles löschen</button>
}
<!-- Mit Direktive – kein Boilerplate in der Component -->
<button *appPermission="'admin'" (click)="deleteAll()">Alles löschen</button>Die Direktiv-Variante zentralisiert die Permission-Logik einmalig. Wenn sich später das Permission-Modell ändert (Rollen-Hierarchien, organisationsspezifische Overrides, Audit-Logging), passiert das an einer einzigen Stelle.
Praxis: *appRepeat – ein Mini-@for
Eine eigene Schleifen-Direktive zeigt das Zusammenspiel zwischen TemplateRef, ViewContainerRef und Context-Objects. Der Trick: Beim Erzeugen der View übergibst du als zweites Argument ein Context-Object. Dessen Properties werden im Template über let-name="property" als Variablen verfügbar; eine spezielle Property $implicit deckt den let item-Default ab.
import {
Directive,
Input,
TemplateRef,
ViewContainerRef,
inject
} from '@angular/core';
interface RepeatContext<T> {
$implicit: T;
index: number;
count: number;
first: boolean;
last: boolean;
}
@Directive({
selector: '[appRepeat]',
standalone: true
})
export class RepeatDirective<T> {
private readonly tpl = inject(TemplateRef<RepeatContext<T>>);
private readonly vcr = inject(ViewContainerRef);
@Input({ required: true }) set appRepeatOf(items: readonly T[]) {
this.vcr.clear();
const count = items.length;
items.forEach((item, index) => {
this.vcr.createEmbeddedView(this.tpl, {
$implicit: item,
index,
count,
first: index === 0,
last: index === count - 1
});
});
}
// Type-Guard für strikten Template-Kontext
static ngTemplateContextGuard<T>(
_dir: RepeatDirective<T>,
ctx: unknown
): ctx is RepeatContext<T> {
return true;
}
}Im Template wird die Direktive mit der bekannten let-Microsyntax verwendet. Beachte: Der primäre Input heißt appRepeat, das of-Schlüsselwort mappt auf appRepeatOf.
<ul>
<li *appRepeat="let user of users();
let i = index;
let last = last">
{{ i + 1 }}. {{ user.name }} {{ last ? '(letzter)' : '' }}
</li>
</ul>Wichtig: Diese Direktive ist eine Lehr-Implementierung, kein Ersatz für @for. Der Built-in Block hat View-Reuse über track, optimiertes Diffing und Compiler-Optimierungen, die hier alle fehlen. Praxis-Direktiven dieser Art lohnen sich nur, wenn die Iteration eine zusätzliche, domänenspezifische Logik trägt (z. B. clientseitiges Filtern + Pagination + Lazy-Load in einer Direktive zusammengefasst).
Lifecycle und Cleanup
Strukturdirektiven leben so lange wie ihr Host-Template. Wird das Host-Element zerstört (z. B. durch ein umschließendes @if), zerstört Angular die Direktiv-Instanz und alle in ihrem ViewContainerRef enthaltenen Views automatisch. Du musst dich nur um externe Resources kümmern – also Subscriptions, Timer oder manuelle Event-Listener.
import {
Directive,
DestroyRef,
TemplateRef,
ViewContainerRef,
effect,
inject
} from '@angular/core';
import { interval } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Directive({
selector: '[appBlinking]',
standalone: true
})
export class BlinkingDirective {
private readonly tpl = inject(TemplateRef<unknown>);
private readonly vcr = inject(ViewContainerRef);
private readonly destroyRef = inject(DestroyRef);
constructor() {
interval(500)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(tick => {
this.vcr.clear();
if (tick % 2 === 0) {
this.vcr.createEmbeddedView(this.tpl);
}
});
}
}takeUntilDestroyed() erkennt das Direktiv-Destroy automatisch über DestroyRef. Ein expliziter ngOnDestroy ist dafür nicht mehr nötig – der zentrale Lifecycle-Hook der Signal-Welt.
Built-in Control Flow vs. Custom – Entscheidungsmatrix
| Use-Case | Built-in (@if/@for) | Eigene Strukturdirektive |
|---|---|---|
| Einfache Bedingung in einer Component | Empfohlen | Overkill |
| Wiederverwendbarer Permission-Check | Möglich, aber Boilerplate | Empfohlen (*appPermission) |
Liste mit track und Diffing | Empfohlen (@for ... track) | Verzichtbar |
| Iteration mit eigener Domänen-Logik | Schnell aufgebläht | Sinnvoll (DSL-Direktive) |
| Mehrere Template-Slots mit Context | @if/@else if/@else | Direktive, wenn wiederverwendbar |
| Type-Narrowing der Bedingung | Ja, automatisch | Nur via ngTemplateContextGuard |
| Compiler-Optimierung | Maximal (Block-Syntax) | Standard-Direktiven-Overhead |
| Kombinierbar mit anderen Direktiven am gleichen Element | Ja (Block umschließt) | Nein (eine * pro Element) |
Die Faustregel: Built-in zuerst, Direktive nur, wenn die Logik wiederverwendbar oder komplex ist. Permission-Checks, Feature-Flags, Lade-Strategien mit mehreren Slots sind die typischen Kandidaten für eigene Direktiven – einfache ifs und fors nicht.
Häufige Stolperfallen
Selector ohne eckige Klammern
selector: ‘appUnless’ macht aus deiner Direktive einen Komponenten-ähnlichen Tag-Selector. Strukturdirektiven brauchen zwingend selector: ‘[appUnless]’ – sonst greift die Microsyntax nicht und der Compiler meldet „Property appUnless does not exist”.
Setter-Pattern vs. input() + effect()
Klassisch wird die Render-Logik im @Input-Setter angestoßen. In der Signal-Welt ist input.required<T>() + effect() sauberer: kein Logikknoten im Setter, automatische Reaktivität auf Service-Signale, die in derselben Computation gelesen werden.
Microsyntax-Naming für sekundäre Inputs
*appPerm=“‘admin’; else fallback” setzt zwei Inputs: appPerm und appPermElse. Die Konvention ist hart – ein Input elseTpl würde von der Microsyntax NICHT erreicht. Wer von der Konvention abweichen will, muss die lange Form mit <ng-template [appPerm]=”…” [elseTpl]=”…”> verwenden.
ViewContainerRef.clear() vergessen
Ohne clear() vor createEmbeddedView() akkumuliert der Container bei jedem Update zusätzliche Views. Optisch wirkt es wie ein „Memory-Leak im DOM”: Liste verdoppelt sich, dann verdreifacht. Bei jeder Direktive, die ihre View ersetzen kann, gehört clear() als erste Zeile in den Update-Pfad.
Eine Strukturdirektive pro Element
<div *appUnless=“x” *appRepeat=”…”> ist Compiler-Fehler. Der Stern verlangt einen einzelnen impliziten <ng-template>-Wrapper – zwei wären nicht repräsentierbar. Workaround: ein neutraler <ng-container *appUnless=“x”> als Außenring, das eigentliche Element mit *appRepeat innen.
Built-in Control Flow ist Compiler-optimiert
@if/@for erzeugen direktive-freien, hochoptimierten Bytecode. Eigene Strukturdirektiven bleiben normale Direktiven mit Standard-Overhead. Bei sehr großen Listen oder hochfrequenten Updates ist der Built-in Block messbar schneller – die eigene Direktive nur dann nehmen, wenn ihre Wiederverwendbarkeit den Overhead rechtfertigt.
Microsyntax kennt nur einfache Ausdrücke
Komplexe Ausdrücke (verschachtelte Pipes, ternäre Ausdrücke über mehrere Zeilen, Optional-Chaining-Ketten) scheitern still im Microsyntax-Parser. Die saubere Lösung ist die lange Form: <ng-template [appDir]=“komplexerAusdruck”>…</ng-template> akzeptiert jede Standard-Bindung.
Direktiven können nicht in @for/@if eingebettet werden – andersrum schon
Ein *appPerm kann jederzeit innerhalb eines @if-Blocks stehen, aber NICHT umgekehrt: @if ist ein Block, kein Direktiv-Element, und ein Stern davor ist Syntax-Fehler. Wer beide Mechanismen mischen will, schreibt zuerst die Block-Schicht und dann die Direktive auf einem Element innerhalb.
Weiterführende Ressourcen
Externe Quellen
- Structural Directives Guide – Angular.dev
- TemplateRef API – Angular.dev
- ViewContainerRef API – Angular.dev
- Built-in Control Flow – Angular.dev