A template isn't static HTML: Angular keeps it connected to the component class. The brackets tell you which way the data goes — {{ }} and [ ] carry values from the class into the DOM, ( ) brings events back into the class, and [( )] does both.
Most beginner bugs here aren't syntax errors. They come from three questions: which way is the data flowing, am I setting a DOM property or an HTML attribute, and which element really received the click?
Four bindings, two directions
Data goes down with {{ }} and [ ], events come up with ( ). Angular re-evaluates the expressions on the right when the signals they read change, and writes to the DOM only if the result is different from what it wrote last time.
| Syntax | Direction | What happens |
|---|---|---|
{{ expr }} | class → DOM | sets the text of a node (always text, never HTML) |
[prop]="expr" | class → DOM | sets a property of the DOM element |
[attr.x]="expr" | class → DOM | sets or removes an HTML attribute |
(event)="stmt" | DOM → class | runs a statement when the event fires |
[(x)]="sig" | both ways | a property binding plus a change event, in one |
<!-- class → DOM -->
<h2>{{ title() }}</h2> <!-- interpolation: text -->
<img [src]="avatarUrl()" [alt]="userName()" /> <!-- property binding -->
<td [attr.colspan]="wide() ? 2 : null"></td> <!-- attribute binding -->
<!-- DOM → class -->
<button (click)="save()">Save</button> <!-- event binding -->
<!-- both ways -->
<app-rating [(value)]="stars" /> <!-- two-way binding -->Interpolation: always text
{{ }} turns the value into a string and puts it in a text node. HTML inside the value is shown as characters, not parsed — that's what protects you from XSS. Signals are read by calling them: {{ count() }}. Keep the expression cheap and free of side effects: it may run on every change detection pass.
<!-- the value becomes text: '<b>Ana</b>' is shown with its brackets, not in bold -->
<p>Hello, {{ userName() }}!</p>
<!-- any expression without side effects; signals are called -->
<p>{{ items().length }} items · {{ total() | currency: 'EUR' }}</p>
<p>{{ user()?.address?.city ?? 'no city yet' }}</p>
<!-- ✗ the text of the signal function, not its value -->
<p>{{ count }}</p>Property binding sets a DOM property
[value]="name()" doesn't touch the HTML. It sets the value property of the input element. Attributes are the starting values written in the HTML; properties are the element's live state. When the user types, the property changes and the attribute stays as it was. And Angular writes the property only when name() changes — so if the class still holds the same value, a reset won't change what the user typed.
@Component({
selector: 'app-profile',
template: `
<!-- sets input.value; does not change the value="" attribute -->
<input [value]="name()" (input)="name.set(box.value)" #box />
<button [disabled]="saving()" (click)="save()">Save</button>
<button (click)="reset()">Reset</button>
`,
})
export class Profile {
readonly name = signal('Ana');
readonly saving = signal(false);
reset() {
// works only because (input) keeps name() up to date;
// with [value] alone, name() is still 'Ana' and set('Ana') changes nothing
this.name.set('Ana');
}
}name()in the class | 'Ana' |
input.valueDOM property | 'Ana' |
getAttribute('value')HTML attribute | null |
in syncThe field and the signal both say 'Ana'.
The first button is disabled: the attribute exists, and its text value doesn't matter.
In the first mode, the field shows text the class knows nothing about. name.set('Ana') gives the signal the value it already had, so nothing changes and Angular doesn't write to the input. Reading the input back with (input) — or binding both ways with [( )] or a form — keeps the class and the DOM in step. Notice that getAttribute('value') stays null throughout: Angular never set the attribute.
When there's no property: [attr.]
Some attributes have no matching property: aria-*, colspan, data-*, attributes on SVG. For those, use [attr.name]. A value of null or undefined removes the attribute, which is how you switch aria-current on and off. Classes and styles have their own bindings, covered in [class] and [style].
Boolean attributes are the classic trap: the browser looks only at whether the attribute exists. disabled="false" is a string, the attribute is present, and the button is disabled. [disabled]="false" sets the property to the boolean false.
<!-- no DOM property for these: bind the attribute -->
<td [attr.colspan]="span()">…</td>
<svg><circle [attr.r]="radius()" /></svg>
<div [attr.data-state]="state()"></div>
<!-- null removes the attribute: aria-current exists only on the active link -->
<a [attr.aria-current]="active() ? 'page' : null" href="/orders">Orders</a>
<!-- ✗ the attribute exists, so the button is disabled -->
<button disabled="false">Save</button>
<!-- ✓ the property gets the boolean false -->
<button [disabled]="false">Save</button>Event binding: ( )
(click)="save()" adds a listener to the element and runs the statement when the event fires. $event is the DOM event object: a MouseEvent for clicks, a KeyboardEvent for keys, or the emitted value for a component's output(). For reading a field, a template reference such as #box is simpler and fully typed: (input)="name.set(box.value)".
<button (click)="save()">Save</button>
<!-- $event is the DOM event; for an output() it's the emitted value -->
<div (pointermove)="track($event.clientX, $event.clientY)"></div>
<app-rating (valueChange)="stars.set($event)" />
<!-- a template reference is typed as HTMLInputElement: no casts -->
<input #box (input)="query.set(box.value)" />
<!-- ✗ in strict mode: 'value' does not exist on type 'EventTarget' -->
<input (input)="query.set($event.target.value)" />Events bubble up
A click starts on the deepest element and then travels up through every ancestor, running their (click) handlers too. That's how a Delete button inside a clickable row also opens the row — the row's handler runs right after the button's.
- Order #1042
- Order #1043
- Order #1044
Nothing clicked yet.
$event.target is the element where the click started, and it's the same in every handler. $event.currentTarget is the element whose handler is running. stopPropagation() in the inner handler is the quick fix. A better structure is often not to nest them at all: make the row's action a real link or button next to Delete, not a click handler on the whole row — that also gives keyboard users a way to open it.
<ul>
@for (order of orders(); track order.id) {
<!-- the row opens the order… -->
<li (click)="open(order)">
Order #{{ order.id }}
<!-- …and without stopPropagation(), a click here reaches the row too -->
<button (click)="remove(order); $event.stopPropagation()">Delete</button>
</li>
}
</ul>
<!-- often better: two separate actions, no nesting, and reachable from the keyboard -->
<li>
<a [routerLink]="['/orders', order.id]">Order #{{ order.id }}</a>
<button (click)="remove(order)">Delete</button>
</li>Key filters and the browser's default action
Angular lets you filter keyboard events right in the binding: (keydown.enter), (keydown.escape), (keydown.shift.enter), (keydown.control.s). The modifiers must match exactly: (keydown.enter) doesn't fire for Shift+Enter. Your handler runs before the browser's default action — inserting a line break, submitting the form, following a link — and $event.preventDefault() cancels that action.
<textarea
#msg
[value]="draft()"
(input)="draft.set(msg.value)"
(keydown.enter)="send($event)"
(keydown.escape)="draft.set('')"
></textarea>
<!-- (keydown.enter) does not fire for Shift+Enter: the browser adds a line, as usual -->(keydown) | — |
(keydown.enter) | — |
(keydown.shift.enter) | — |
(keydown.escape) | — |
Press a key in the message box.
Without preventDefault(), the browser inserts the line break after your handler has emptied draft, and the (input) event writes the text back. One more detail most chat boxes get wrong: while someone types Japanese or Chinese with an input method, Enter confirms the characters. Check $event.isComposing and ignore the key in that case.
[( )] is [ ] plus ( )
The “banana in a box” is shorthand. [(value)]="name" means a property binding on value plus a listener for the valueChange event that writes back. When you pass a WritableSignal, Angular sets the signal for you. On your own components, model() creates both halves; for form fields, use Signal Forms.
<!-- the shorthand… -->
<app-rating [(value)]="stars" />
<!-- …means exactly this -->
<app-rating [value]="stars()" (valueChange)="stars.set($event)" />
<!-- with a WritableSignal, Angular calls set() for you;
the child declares it with: value = model(0) -->Template reference variables
#name on an element gives you that element in the rest of the template; on a component, it gives you the component instance. It's the quickest way to read a field or call focus() without going through $event.target, whose type is only EventTarget. In the class, the same reference is reached with viewChild().
<!-- on an element: the element itself -->
<input #search placeholder="Search" />
<button (click)="search.focus()">Focus</button>
<!-- on a component: the component instance -->
<app-video-player #player />
<button (click)="player.play()">Play</button>
<!-- in the class: readonly search = viewChild<ElementRef<HTMLInputElement>>('search') -->Good habits / avoid
- Keep the source of truth in the class (a signal) and read every change back from the DOM.
- Use template references (#box) instead of $event.target when you need a value.
- Use [attr.] for aria-* and data-*, and null to remove an attribute.
- Calling functions that do heavy work in {{ }} or [ ] — they run again on every pass.
- Static boolean attributes with a value (disabled="false"): the attribute's presence is all that counts.
- stopPropagation() as a habit: it also hides the event from analytics, from menus that close when you click outside, and from your colleagues.
Common pitfalls
- A reset that doesn't reset. The field was bound one way, the user typed, and the class still holds the old value. Setting it again changes nothing.
- Forgetting the parentheses.
{{ count }}prints the signal function itself, not its value. The compiler warns about it; don't ignore the warning. - The row opens when you click Delete. The click bubbled from the button to the row. Stop it, or don't nest actions.
- A line break left behind after sending. Enter's default action ran after the handler. Call
$event.preventDefault(). - Typing
$event.target.value.targetis anEventTarget, so this fails to compile in strict mode. Use a template reference, or read it in the class with a cast.
Cheat sheet
{{ expr }} text into the DOM; HTML is escaped; call signals: {{ count() }}
[prop]="expr" a DOM property (value, disabled, src, checked) or a component input
[attr.x]="expr" an HTML attribute (aria-*, data-*, colspan, svg); null removes it
[class.x] / [style.x] one class or one style; see the [class] and [style] page
(event)="stmt" a listener; $event = the DOM event or the emitted value
(keydown.enter) key filters; modifiers must match: shift.enter, control.s, escape
[(x)]="sig" [x]="sig()" + (xChange)="sig.set($event)"
#ref the element, or the component instance, in the template
when writes happen only when the value differs from the last one Angular wrote
attribute vs property attribute = starting value in the HTML; property = live state
bubbling a click runs the handlers of every ancestor; target stays the same
default actions run after your handler; $event.preventDefault() cancels them