Lange bevor Angular 17 den Built-in Control Flow (@if, @for, @switch) brachte, waren *ngIf, *ngFor und *ngSwitch der Standard für bedingtes Rendern und Listen. In riesigen Bestands-Codebasen sind sie es bis heute. Das Angular-Team hat sie seit v20 zwar als deprecated markiert – aber „deprecated” heißt hier ausdrücklich nicht „bald entfernt”: Sie funktionieren weiterhin vollständig, werden weiter unterstützt und bleiben in jedem Tutorial und jeder Library-Doku der Welt präsent. Wer Angular ernsthaft schreibt oder liest, kommt an ihnen nicht vorbei. Dieser Artikel zeigt jede Direktive im Detail, das Desugaring der Microsyntax, eigene Strukturdirektiven und den Migrationspfad zum neuen Control Flow.

Strukturdirektiven verstehen

Eine Strukturdirektive manipuliert die DOM-Struktur ihres Host-Elements – sie fügt Elemente ein, entfernt sie oder vervielfältigt sie. Anders als Attribute-Direktiven (die Eigenschaften eines bestehenden Elements verändern) entscheidet eine Strukturdirektive, ob und wie oft das Element überhaupt gerendert wird.

Erkennbar am Asterisk-Präfix: *ngIf, *ngFor, *ngSwitchCase. Der Stern ist Syntactic Sugar des Angular-Compilers für ein wrapping <ng-template>-Element. Die Direktive selbst arbeitet auf diesem Template, instantiiert es bei Bedarf in einen ViewContainerRef und entfernt die Instanz wieder, wenn die Bedingung wegfällt.

*ngIf – Grundlagen und Varianten

*ngIf rendert sein Host-Template, wenn der Ausdruck truthy ist. Drei Eingaben sind möglich: ngIf (der Ausdruck), ngIfThen und ngIfElse (jeweils ein TemplateRef). In Standalone-Komponenten musst du NgIf oder CommonModule explizit importieren.

TypeScript ngif-demo.component.ts
import { Component, signal } from '@angular/core';
import { NgIf, AsyncPipe } from '@angular/common';
import { Observable, of } from 'rxjs';

interface User { name: string; role: string; }

@Component({
    selector: 'app-ngif-demo',
    standalone: true,
    imports: [NgIf, AsyncPipe],
    template: `
        <!-- Variante 1: Einfaches Rendern -->
        <p *ngIf="loggedIn()">Willkommen zurück.</p>

        <!-- Variante 2: if/else mit ng-template -->
        <div *ngIf="loggedIn(); else guestBlock">
            <button (click)="loggedIn.set(false)">Abmelden</button>
        </div>
        <ng-template #guestBlock>
            <button (click)="loggedIn.set(true)">Anmelden</button>
        </ng-template>

        <!-- Variante 3: then/else explizit -->
        <div *ngIf="loggedIn(); then loggedTpl else guestTpl"></div>
        <ng-template #loggedTpl><p>Eingeloggt</p></ng-template>
        <ng-template #guestTpl><p>Gast</p></ng-template>

        <!-- Variante 4: as-Variable für Async-Pipe -->
        <section *ngIf="user$ | async as user">
            <h2>{{ user.name }}</h2>
            <p>Rolle: {{ user.role }}</p>
        </section>
    `
})
export class NgIfDemoComponent {
    loggedIn = signal(true);
    user$: Observable<User> = of({ name: 'Mia', role: 'Admin' });
}

Variante 4 ist der wichtigste Einsatzfall: Mit as user wird der Async-Pipe-Wert in einer Template-Variable gespeichert und ist im ganzen Block wiederverwendbar. Ohne as müsstest du user$ | async mehrfach schreiben – jedes Mal wäre eine neue Subscription die Folge.

*ngFor mit allen Helfern und trackBy

*ngFor iteriert über jedes Iterable<T> (Array, Map, Set). Neben dem Element selbst stellt die Direktive sieben Kontextvariablen bereit, die du per as-Pattern in lokale Template-Variablen exportierst.

VariableTypBedeutung
indexnumber0-basierter Index
countnumberLänge der Iterable
firstbooleanErstes Item?
lastbooleanLetztes Item?
evenbooleanGerader Index?
oddbooleanUngerader Index?
$implicitTDas Item selbst (let item)
TypeScript user-list.component.ts
import { Component, signal } from '@angular/core';
import { NgFor, NgClass } from '@angular/common';

interface User { id: string; name: string; }

@Component({
    selector: 'app-user-list',
    standalone: true,
    imports: [NgFor, NgClass],
    template: `
        <ul>
            <li
                *ngFor="let user of users();
                        let i = index;
                        let first = first;
                        let last = last;
                        let odd = odd;
                        trackBy: trackById"
                [ngClass]="{ first, last, odd }"
            >
                {{ i + 1 }}. {{ user.name }}
                <button (click)="remove(user.id)">Entfernen</button>
            </li>
        </ul>
        <button (click)="add()">User hinzufügen</button>
    `
})
export class UserListComponent {
    users = signal<User[]>([
        { id: 'u1', name: 'Alice' },
        { id: 'u2', name: 'Bob' },
        { id: 'u3', name: 'Charlie' }
    ]);

    // Identitäts-Tracking via stabile ID
    trackById = (_: number, u: User) => u.id;

    add() {
        this.users.update(list => [...list, {
            id: crypto.randomUUID(),
            name: 'Neu ' + (list.length + 1)
        }]);
    }

    remove(id: string) {
        this.users.update(list => list.filter(u => u.id !== id));
    }
}

trackBy ist bei kleinen Listen optional, bei großen oder häufig wechselnden Listen aber Pflicht: Ohne trackBy vergleicht Angular per Objekt-Identität. Wird das Array komplett neu zugewiesen (z.B. nach einem HTTP-Reload), hält Angular jedes Item für „neu” und rendert das gesamte DOM neu – inklusive aller Child-Komponenten und Animationen. Mit trackBy auf eine stabile ID erkennt Angular „gleiches Objekt, anderer Snapshot” und erhält die DOM-Knoten.

*ngSwitch / *ngSwitchCase / *ngSwitchDefault

ngSwitch arbeitet als Triple: Ein Container-Element trägt [ngSwitch]="expr" (kein Asterisk – das ist eine reguläre Property-Bindung), darunter stehen Kindelemente mit *ngSwitchCase="value" und optional eines mit *ngSwitchDefault.

TypeScript status-badge.component.ts
import { Component, input } from '@angular/core';
import { NgSwitch, NgSwitchCase, NgSwitchDefault } from '@angular/common';

type OrderStatus = 'pending' | 'paid' | 'shipped' | 'cancelled';

@Component({
    selector: 'app-status-badge',
    standalone: true,
    imports: [NgSwitch, NgSwitchCase, NgSwitchDefault],
    template: `
        <div [ngSwitch]="status()" class="badge">
            <span *ngSwitchCase="'pending'" class="badge-warn">
                Warten auf Zahlung
            </span>
            <span *ngSwitchCase="'paid'" class="badge-info">
                Bezahlt
            </span>
            <span *ngSwitchCase="'shipped'" class="badge-success">
                Versendet
            </span>
            <span *ngSwitchCase="'cancelled'" class="badge-error">
                Storniert
            </span>
            <span *ngSwitchDefault class="badge-neutral">
                Unbekannt
            </span>
        </div>
    `
})
export class StatusBadgeComponent {
    status = input.required<OrderStatus>();
}

Im Vergleich zu @switch ist die ngSwitch-Variante deutlich verbose – drei Direktiven statt einer Block-Syntax, und der Compiler kann nicht type-narrowen. Genau das hat das Team bei @switch deutlich verbessert.

*ngTemplateOutlet und <ng-container>

Zwei Helfer, die häufig zusammen mit den drei Hauptdirektiven auftauchen:

  • <ng-container> ist ein logischer Wrapper, der kein DOM-Element erzeugt. Ideal, um Strukturdirektiven anzuwenden, ohne ein zusätzliches <div> ins DOM zu pumpen.
  • *ngTemplateOutlet rendert ein TemplateRef – optional mit einem Context-Object, dessen Properties als Template-Variablen verfügbar werden.
TypeScript template-outlet-demo.component.ts
import { Component, signal } from '@angular/core';
import { NgFor, NgIf, NgTemplateOutlet } from '@angular/common';

@Component({
    selector: 'app-template-outlet-demo',
    standalone: true,
    imports: [NgFor, NgIf, NgTemplateOutlet],
    template: `
        <!-- ng-container: kein zusätzliches DOM-Element -->
        <ng-container *ngIf="users().length > 0; else emptyTpl">
            <h2>Nutzer</h2>
            <ul>
                <li *ngFor="let u of users()">{{ u }}</li>
            </ul>
        </ng-container>

        <!-- TemplateOutlet mit Context -->
        <ng-template #greetingTpl let-name="name" let-role="role">
            <p>Hallo {{ name }} ({{ role }})</p>
        </ng-template>

        <ng-container
            *ngTemplateOutlet="greetingTpl;
                               context: { name: 'Mia', role: 'Admin' }">
        </ng-container>

        <ng-template #emptyTpl>
            <p>Keine Nutzer vorhanden.</p>
        </ng-template>
    `
})
export class TemplateOutletDemoComponent {
    users = signal(['Alice', 'Bob']);
}

*ngTemplateOutlet ist außerdem das Mittel der Wahl, um polymorphe Listen zu rendern – also pro Item-Typ ein anderes Template zu wählen.

Microsyntax-Desugaring

Der Asterisk ist reine Compiler-Magie. So expandiert Angular *ngIf:

HTML microsyntax-desugar.html
<!-- Du schreibst: -->
<div *ngIf="user; else guestBlock">
    {{ user.name }}
</div>
<ng-template #guestBlock>Gast</ng-template>

<!-- Der Compiler macht daraus: -->
<ng-template [ngIf]="user" [ngIfElse]="guestBlock">
    <div>{{ user.name }}</div>
</ng-template>
<ng-template #guestBlock>Gast</ng-template>

Analog für *ngFor:

HTML ngfor-desugar.html
<!-- Microsyntax -->
<li *ngFor="let item of items; let i = index; trackBy: trackById">
    {{ i }}: {{ item.name }}
</li>

<!-- Expandiert zu -->
<ng-template ngFor
             let-item
             [ngForOf]="items"
             let-i="index"
             [ngForTrackBy]="trackById">
    <li>{{ i }}: {{ item.name }}</li>
</ng-template>

Genau aus diesem Grund ist nur eine Strukturdirektive pro Element erlaubt: Der Stern verlangt einen einzelnen impliziten <ng-template>-Wrapper. Zwei Sterne würden zwei Wrapper auf einer Position fordern – nicht repräsentierbar.

Eigene Strukturdirektiven schreiben

Mit TemplateRef und ViewContainerRef baust du in zehn Zeilen eigene Strukturdirektiven. Der Klassiker: ein invertiertes *ngIf namens *appUnless.

TypeScript unless.directive.ts
import {
    Directive,
    Input,
    TemplateRef,
    ViewContainerRef
} from '@angular/core';

@Directive({
    selector: '[appUnless]',
    standalone: true
})
export class UnlessDirective {
    private hasView = false;

    constructor(
        private tpl: TemplateRef<unknown>,
        private vcr: ViewContainerRef
    ) {}

    @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:

HTML unless-usage.html
<p *appUnless="isLoggedIn()">
    Bitte melde dich an, um Inhalte zu sehen.
</p>

Wichtig: Der Selector muss in eckigen Klammern stehen und die @Input-Property muss exakt den gleichen Namen wie der Selector tragen. Der Compiler verbindet beides automatisch via Microsyntax.

Migration zu @if / @for / @switch

Angular liefert ein automatisches Migrationsschematic mit. Es konvertiert in den meisten Fällen 1:1:

bash terminal
ng generate @angular/core:control-flow

Vorher/Nachher im direkten Vergleich:

Klassisch (*ngIf/*ngFor/*ngSwitch)Built-in Control Flow
<div *ngIf="x">...</div>@if (x) { <div>...</div> }
<div *ngIf="x; else other">A</div><ng-template #other>B</ng-template>@if (x) { A } @else { B }
<li *ngFor="let i of items; trackBy: byId">@for (i of items; track i.id) { <li>... }
[ngSwitch] mit *ngSwitchCase/*ngSwitchDefault@switch (x) { @case (...) {} @default {} }
CommonModule muss importiert seinKein Import nötig

Was das Schematic nicht zuverlässig erledigt:

  • Komplexe then/else-Patterns mit drei oder mehr Templates – die musst du manuell zusammenfassen.
  • trackBy-Funktionen werden 1:1 übernommen, aber bei @for ist track Pflicht und nutzt einen direkt eingebetteten Ausdruck (track item.id) statt einer separaten Methode.
  • Kombinationen aus *ngIf und *ngFor über <ng-container> werden korrekt entwirrt, aber das Ergebnis wirkt manchmal unidiomatisch – ein zweiter Blick lohnt.

Häufige Stolperfallen

Zwei Strukturdirektiven auf einem Element

<div *ngIf=“x” *ngFor=“let i of items”> bricht den Build mit einer kryptischen „Can’t have multiple template bindings”-Meldung. Lösung: Verschachtelung über <ng-container *ngIf=“x”><div *ngFor=“let i of items”>…</div></ng-container> oder direkt der neue Built-in Control Flow.

*ngFor ohne trackBy bei großen Listen

Wechselt die Array-Reference (z. B. nach einem Reload), behandelt Angular ohne trackBy jedes Item als neu – das gesamte DOM wird neu aufgebaut, alle Child-Komponenten remountet. Bei Listen ab ~50 Items spürbar lahm. Eine simple trackBy: byId-Funktion löst das vollständig.

CommonModule fehlt in Standalone-Component

Fehlermeldung „Can’t bind to ngForOf since it isn’t a known property of li” oder „NG8002” deutet fast immer auf einen fehlenden Import hin. In Standalone-Components musst du NgIf, NgFor, NgSwitch einzeln importieren oder pauschal CommonModule. Beim Built-in Control Flow entfällt das.

Verschachtelte ng-template-Else-Blöcke werden unleserlich

Drei oder mehr verschachtelte *ngIf=”…; else …” mit eigenen <ng-template>-Definitionen weiter unten im Template kippen die Lesbarkeit. Spätestens hier lohnt sich der Wechsel zu @switch, eine eigene Komponente oder eine Computed-Signal-getriebene String-Auswahl.

*ngFor-Variablen sind nur im Template verfügbar

let i = index oder let last existieren ausschließlich im Template-Scope. Aus dem Component-Code (oder per @ViewChild) kommst du nicht an sie heran. Wer den Index außerhalb braucht, muss ihn beim Eventhandling explizit mitschicken: (click)=“select(item, i)”.

*ngTemplateOutlet wird unterschätzt

Mit Context-Object und einer Map von Templates pro Item-Typ rendert *ngTemplateOutlet heterogene Listen (Headlines, Cards, Ads, …) im selben *ngFor – ohne hässliche *ngIf-Kaskaden. Lese-Tipp: ngTemplateOutletContext akzeptiert exakt dasselbe Format wie die Variablen-Bindings im <ng-template>.

Migration ist konservativ – nicht erschöpfend

Das ng generate @angular/core:control-flow-Schematic deckt 80–90 % der Fälle. Bei vielen verschachtelten *ngIf+*ngFor+else-Templates bleibt manuelle Nacharbeit. Plane bei großen Codebasen einen dedizierten Refactor-Sprint statt einer Drive-by-Migration.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Templates & Control Flow

Zur Übersicht