Ein Template ist kein statisches HTML: Angular hält es mit der Klasse der Komponente verbunden. Die Klammern sagen dir, in welche Richtung die Daten fließen — {{ }} und [ ] bringen Werte aus der Klasse ins DOM, ( ) bringt Events zurück in die Klasse, und [( )] macht beides.
Die meisten Anfängerfehler hier sind keine Syntaxfehler. Sie kommen aus drei Fragen: In welche Richtung fließen die Daten, setze ich eine DOM-Property oder ein HTML-Attribut, und welches Element hat den Klick wirklich bekommen?
Vier Bindings, zwei Richtungen
Daten gehen mit {{ }} und [ ] nach unten, Events kommen mit ( ) nach oben. Angular wertet die Ausdrücke rechts neu aus, wenn sich die gelesenen Signals ändern, und schreibt ins DOM nur, wenn das Ergebnis anders ist als beim letzten Mal.
| Syntax | Richtung | Was passiert |
|---|---|---|
{{ expr }} | Klasse → DOM | setzt den Text eines Knotens (immer Text, nie HTML) |
[prop]="expr" | Klasse → DOM | setzt eine Property des DOM-Elements |
[attr.x]="expr" | Klasse → DOM | setzt oder entfernt ein HTML-Attribut |
(event)="stmt" | DOM → Klasse | führt eine Anweisung aus, wenn das Event eintritt |
[(x)]="sig" | in beide Richtungen | ein Property-Binding plus ein Änderungs-Event, in einem |
<!-- Klasse → DOM -->
<h2>{{ title() }}</h2> <!-- Interpolation: Text -->
<img [src]="avatarUrl()" [alt]="userName()" /> <!-- Property-Binding -->
<td [attr.colspan]="wide() ? 2 : null"></td> <!-- Attribut-Binding -->
<!-- DOM → Klasse -->
<button (click)="save()">Speichern</button> <!-- Event-Binding -->
<!-- in beide Richtungen -->
<app-rating [(value)]="stars" /> <!-- Two-Way-Binding -->Interpolation: immer Text
{{ }} macht aus dem Wert einen String und setzt ihn in einen Textknoten. HTML im Wert erscheint als Zeichen und wird nicht interpretiert — das schützt dich vor XSS. Signals liest man, indem man sie aufruft: {{ count() }}. Halte den Ausdruck billig und ohne Seiteneffekte: Er kann bei jedem Durchlauf der Change Detection laufen.
<!-- der Wert wird Text: '<b>Ana</b>' erscheint mit seinen Klammern, nicht fett -->
<p>Hallo, {{ userName() }}!</p>
<!-- jeder Ausdruck ohne Seiteneffekte; Signals werden aufgerufen -->
<p>{{ items().length }} Artikel · {{ total() | currency: 'EUR' }}</p>
<p>{{ user()?.address?.city ?? 'noch keine Stadt' }}</p>
<!-- ✗ der Text der Signal-Funktion, nicht ihr Wert -->
<p>{{ count }}</p>Property-Binding setzt eine DOM-Property
[value]="name()" fasst das HTML nicht an. Es setzt die Property value des Input-Elements. Attribute sind die Startwerte im HTML; Properties sind der aktuelle Zustand des Elements. Wenn der Nutzer tippt, ändert sich die Property, das Attribut bleibt, wie es war. Und Angular schreibt die Property nur, wenn sich name() ändert — hält die Klasse noch denselben Wert, ändert ein Zurücksetzen nichts an dem, was der Nutzer getippt hat.
@Component({
selector: 'app-profile',
template: `
<!-- setzt input.value; ändert das Attribut value="" nicht -->
<input [value]="name()" (input)="name.set(box.value)" #box />
<button [disabled]="saving()" (click)="save()">Speichern</button>
<button (click)="reset()">Zurücksetzen</button>
`,
})
export class Profile {
readonly name = signal('Ana');
readonly saving = signal(false);
reset() {
// funktioniert nur, weil (input) name() aktuell hält;
// mit [value] allein ist name() noch 'Ana', und set('Ana') ändert nichts
this.name.set('Ana');
}
}name()in der Klasse | 'Ana' |
input.valueDOM-Property | 'Ana' |
getAttribute('value')HTML-Attribut | null |
synchronFeld und Signal sagen beide 'Ana'.
Der erste Button ist deaktiviert: Das Attribut existiert, und sein Textwert spielt keine Rolle.
Im ersten Modus zeigt das Feld Text, von dem die Klasse nichts weiß. name.set('Ana') gibt dem Signal den Wert, den es schon hatte; also ändert sich nichts, und Angular schreibt nicht ins Input. Liest du das Input mit (input) zurück — oder bindest du in beide Richtungen mit [( )] oder einem Formular —, bleiben Klasse und DOM im Gleichschritt. Beachte, dass getAttribute('value') die ganze Zeit null bleibt: Angular hat das Attribut nie gesetzt.
Wenn es keine Property gibt: [attr.]
Manche Attribute haben keine passende Property: aria-*, colspan, data-*, Attribute an SVG. Dafür nimmst du [attr.name]. Ein Wert null oder undefined entfernt das Attribut — so schaltest du aria-current ein und aus. Klassen und Styles haben eigene Bindings, erklärt unter [class] und [style].
Boolesche Attribute sind die klassische Falle: Der Browser schaut nur, ob das Attribut existiert. disabled="false" ist ein String, das Attribut ist da, und der Button ist deaktiviert. [disabled]="false" setzt die Property auf den Boolean false.
<!-- dafür gibt es keine DOM-Property: das Attribut binden -->
<td [attr.colspan]="span()">…</td>
<svg><circle [attr.r]="radius()" /></svg>
<div [attr.data-state]="state()"></div>
<!-- null entfernt das Attribut: aria-current gibt es nur am aktiven Link -->
<a [attr.aria-current]="active() ? 'page' : null" href="/orders">Bestellungen</a>
<!-- ✗ das Attribut existiert, also ist der Button deaktiviert -->
<button disabled="false">Speichern</button>
<!-- ✓ die Property bekommt den Boolean false -->
<button [disabled]="false">Speichern</button>Event-Binding: ( )
(click)="save()" hängt einen Listener an das Element und führt die Anweisung aus, wenn das Event eintritt. $event ist das DOM-Event-Objekt: ein MouseEvent bei Klicks, ein KeyboardEvent bei Tasten oder der ausgegebene Wert beim output() einer Komponente. Um ein Feld zu lesen, ist eine Template-Referenz wie #box einfacher und vollständig typisiert: (input)="name.set(box.value)".
<button (click)="save()">Speichern</button>
<!-- $event ist das DOM-Event; bei einem output() der ausgegebene Wert -->
<div (pointermove)="track($event.clientX, $event.clientY)"></div>
<app-rating (valueChange)="stars.set($event)" />
<!-- eine Template-Referenz ist als HTMLInputElement typisiert: keine Casts -->
<input #box (input)="query.set(box.value)" />
<!-- ✗ im Strict Mode: 'value' does not exist on type 'EventTarget' -->
<input (input)="query.set($event.target.value)" />Events steigen auf (Bubbling)
Ein Klick beginnt beim innersten Element und wandert dann durch jeden Vorfahren nach oben, wobei auch deren (click)-Handler laufen. So öffnet ein Löschen-Button in einer klickbaren Zeile auch die Zeile — der Handler der Zeile läuft direkt nach dem des Buttons.
- Bestellung #1042
- Bestellung #1043
- Bestellung #1044
Noch nichts geklickt.
$event.target ist das Element, auf dem der Klick begonnen hat, und in jedem Handler dasselbe. $event.currentTarget ist das Element, dessen Handler gerade läuft. stopPropagation() im inneren Handler ist die schnelle Lösung. Besser ist oft, gar nicht zu verschachteln: Die Aktion der Zeile wird ein echter Link oder Button neben Löschen, kein Klick-Handler auf der ganzen Zeile — dann können sie auch Tastaturnutzer öffnen.
<ul>
@for (order of orders(); track order.id) {
<!-- die Zeile öffnet die Bestellung… -->
<li (click)="open(order)">
Bestellung #{{ order.id }}
<!-- …und ohne stopPropagation() erreicht ein Klick hier auch die Zeile -->
<button (click)="remove(order); $event.stopPropagation()">Löschen</button>
</li>
}
</ul>
<!-- oft besser: zwei getrennte Aktionen, keine Verschachtelung, per Tastatur erreichbar -->
<li>
<a [routerLink]="['/orders', order.id]">Bestellung #{{ order.id }}</a>
<button (click)="remove(order)">Löschen</button>
</li>Tastenfilter und die Standardaktion des Browsers
Angular lässt dich Tastatur-Events direkt im Binding filtern: (keydown.enter), (keydown.escape), (keydown.shift.enter), (keydown.control.s). Die Modifier müssen genau passen: (keydown.enter) feuert nicht bei Shift+Enter. Dein Handler läuft vor der Standardaktion des Browsers — einen Zeilenumbruch einfügen, das Formular absenden, einem Link folgen —, und $event.preventDefault() bricht diese Aktion ab.
<textarea
#msg
[value]="draft()"
(input)="draft.set(msg.value)"
(keydown.enter)="send($event)"
(keydown.escape)="draft.set('')"
></textarea>
<!-- (keydown.enter) feuert nicht bei Shift+Enter: der Browser fügt wie gewohnt eine Zeile ein -->(keydown) | — |
(keydown.enter) | — |
(keydown.shift.enter) | — |
(keydown.escape) | — |
Drück eine Taste im Nachrichtenfeld.
Ohne preventDefault() fügt der Browser den Zeilenumbruch ein, nachdem dein Handler draft geleert hat, und das (input)-Event schreibt den Text zurück. Noch ein Detail, das die meisten Chatfelder falsch machen: Solange jemand Japanisch oder Chinesisch mit einer Eingabemethode (IME) tippt, bestätigt Enter die Zeichen. Prüfe $event.isComposing und ignoriere die Taste in diesem Fall.
[( )] ist [ ] plus ( )
Die „Banane in der Box“ ist eine Abkürzung. [(value)]="name" bedeutet ein Property-Binding auf value plus einen Listener für das Event valueChange, der zurückschreibt. Übergibst du ein WritableSignal, setzt Angular das Signal für dich. In deinen eigenen Komponenten erzeugt model() beide Hälften; für Formularfelder nimm Signal Forms.
<!-- die Kurzform… -->
<app-rating [(value)]="stars" />
<!-- …bedeutet genau das -->
<app-rating [value]="stars()" (valueChange)="stars.set($event)" />
<!-- mit einem WritableSignal ruft Angular set() für dich auf;
das Kind deklariert es mit: value = model(0) -->Template-Referenzvariablen
#name an einem Element gibt dir das Element im restlichen Template; an einer Komponente die Instanz der Komponente. Das ist der schnellste Weg, ein Feld zu lesen oder focus() aufzurufen, ohne über $event.target zu gehen, dessen Typ nur EventTarget ist. In der Klasse erreichst du dieselbe Referenz mit viewChild().
<!-- an einem Element: das Element selbst -->
<input #search placeholder="Suchen" />
<button (click)="search.focus()">Fokus</button>
<!-- an einer Komponente: die Instanz der Komponente -->
<app-video-player #player />
<button (click)="player.play()">Abspielen</button>
<!-- in der Klasse: readonly search = viewChild<ElementRef<HTMLInputElement>>('search') -->Gute Gewohnheiten / vermeiden
- Die Quelle der Wahrheit in der Klasse halten (ein Signal) und jede Änderung aus dem DOM zurücklesen.
- Template-Referenzen (#box) statt $event.target verwenden, wenn du einen Wert brauchst.
- [attr.] für aria-* und data-* nutzen und null, um ein Attribut zu entfernen.
- Funktionen mit viel Arbeit in {{ }} oder [ ] — sie laufen bei jedem Durchlauf erneut.
- Statische boolesche Attribute mit Wert (disabled="false"): Es zählt nur, dass das Attribut da ist.
- stopPropagation() aus Gewohnheit: Es versteckt das Event auch vor Analytics, vor Menüs, die sich bei einem Klick außerhalb schließen, und vor deinen Kollegen.
Häufige Stolperfallen
- Ein Reset, der nicht zurücksetzt. Das Feld war nur in eine Richtung gebunden, der Nutzer hat getippt, und die Klasse hält noch den alten Wert. Ihn erneut zu setzen, ändert nichts.
- Vergessene Klammern.
{{ count }}gibt die Signal-Funktion selbst aus, nicht ihren Wert. Der Compiler warnt davor; ignoriere die Warnung nicht. - Die Zeile öffnet sich beim Klick auf Löschen. Der Klick ist vom Button zur Zeile aufgestiegen. Stoppe ihn oder verschachtle keine Aktionen.
- Ein Zeilenumbruch bleibt nach dem Senden stehen. Die Standardaktion von Enter lief nach dem Handler. Ruf
$event.preventDefault()auf. $event.target.valueschreiben.targetist einEventTarget, im Strict Mode kompiliert das nicht. Nimm eine Template-Referenz oder lies es in der Klasse mit einem Cast.
Spickzettel
{{ expr }} Text ins DOM; HTML wird escapt; Signals aufrufen: {{ count() }}
[prop]="expr" eine DOM-Property (value, disabled, src, checked) oder ein Komponenten-Input
[attr.x]="expr" ein HTML-Attribut (aria-*, data-*, colspan, svg); null entfernt es
[class.x] / [style.x] eine Klasse oder ein Style; siehe die Seite [class] und [style]
(event)="stmt" ein Listener; $event = das DOM-Event oder der ausgegebene Wert
(keydown.enter) Tastenfilter; Modifier müssen passen: shift.enter, control.s, escape
[(x)]="sig" [x]="sig()" + (xChange)="sig.set($event)"
#ref das Element oder die Komponenteninstanz, im Template
wann geschrieben wird nur wenn der Wert anders ist als der zuletzt geschriebene
Attribut vs. Property Attribut = Startwert im HTML; Property = aktueller Zustand
Bubbling ein Klick führt die Handler aller Vorfahren aus; target bleibt gleich
Standardaktionen laufen nach deinem Handler; $event.preventDefault() bricht sie ab