Angular – Templates and Data Binding

June 9, 20266 min readUpdated 8/21/2026

An Angular template is HTML with four extra pieces of syntax bolted on. Learn what those four are and there is very little left to learn — everything else in a template is either ordinary HTML or the control flow from the next lesson.

The four are worth memorising as a set, because the punctuation tells you which one you are looking at:

{{ value }}              interpolation  — put a value into the text
[property]="expr"        property binding — set something ON the element
(event)="handler()"      event binding  — react to something the element does
[(thing)]="expr"         two-way        — both of the above at once

Square brackets mean data flowing in. Round brackets mean events flowing out. The two-way form is literally both symbols together, which is why it is nicknamed the banana in a box.

Interpolation

{{ }} evaluates an expression and puts the result into the page as text. From the product card:

<h3 class="card-title h6 fw-bold mb-1">{{ product().name }}</h3>
<p class="card-text text-muted small flex-grow-1">{{ product().description }}</p>

Note the parentheses. product is a signal, so product() reads it — and reading a signal inside a template is what subscribes the template to it. Forget the parentheses and you interpolate the signal function itself, which renders something like function computed() and is one of the more confusing first-week bugs.

The expression can do a little work. A ternary and a pipe are both fine:

<div class="product-thumb" aria-hidden="true">{{ isPizza() ? '🍕' : '🥤' }}</div>
...
<span class="fw-bold">
  from <span class="text-pizza-red">{{ cheapest() | money }}</span>
</span>

Whatever the expression returns is converted to a string. null and undefined render as nothing at all, rather than as the words — which is usually what you want and occasionally hides a bug.

Property binding

[thing]="expr" sets a property on the element or component. This is how data reaches a child component:

<app-cart-drawer [open]="cartOpen()" (closed)="cartOpen.set(false)" />

open is an input() on CartDrawer; closed is an output(). Data in, events out, in one line — that is the shape of nearly every parent/child relationship in Angular, and it has a lesson of its own shortly.

Why the brackets matter

These two are not the same thing:

<img src="imageUrl" />     <!-- the literal string "imageUrl" -->
<img [src]="imageUrl" />   <!-- the VALUE of imageUrl -->

Without brackets the right-hand side is a string, exactly as in plain HTML. With brackets it is an expression that Angular evaluates. This is the single most common early mistake, and it fails quietly: the image just does not load.

Attribute binding

Property binding sets a DOM property. Most HTML attributes have a matching property, so [disabled], [value] and [src] all work. Some have no property at all — everything aria-*, everything on an SVG element, and colspan among others. For those, say so explicitly:

<button
  type="button"
  class="btn btn-sm btn-outline-danger"
  [disabled]="self"
  [attr.title]="self ? 'You cannot delete your own account' : null"
  (click)="remove(row)"
>
  Delete
</button>

[disabled] is a property; [attr.title] is an attribute. And the null in that ternary is doing real work: binding an attribute to null removes it, rather than setting it to the string "null". That is the idiomatic way to have an attribute present only sometimes.

Class and style bindings

A special case of property binding, and worth knowing because the alternative — building a class string by hand — is miserable.

<div
  class="modal-dialog modal-dialog-centered"
  [class.modal-lg]="size() === 'lg'"
  [class.modal-dialog-scrollable]="scrollable()"
>

[class.x]="condition" adds the class x when the condition is truthy and removes it when it is not. The static class attribute alongside it is left alone — the two combine rather than fight, so the shared classes stay declarative and only the conditional ones need an expression.

Style bindings work the same way and add something genuinely useful — a unit suffix:

<div
  style="height: 10px; border-radius: 4px; background: var(--viz-series-1)"
  [style.width.%]="row.width"
></div>

[style.width.%]="row.width" takes the number 42 and writes width: 42%. The chart components use [style.left.px] the same way. It saves the string concatenation that would otherwise appear at every one of these.

Two-way binding

[(x)] is sugar for a property binding and an event binding together:

<input [(ngModel)]="email" />

<!-- exactly the same thing, written out -->
<input [ngModel]="email" (ngModelChange)="email = $event" />

It is still one-way data flow underneath, which is the point worth holding on to. Angular is not doing anything magic; it is a naming convention — a property x plus an output called xChange — and you can put it on your own components.

The pizza app's login form deliberately writes the long form, [ngModel] and (ngModelChange) separately, because its fields are signals and email.set($event) is not an assignment target. That trade-off belongs to the forms lesson.

Template reference variables

#name on an element gives you a handle to it, usable elsewhere in the same template — or in the class:

<button
  #closeButton
  type="button"
  class="btn-close"
  aria-label="Close"
  (click)="closed.emit()"
></button>
private readonly closeButton = viewChild<ElementRef<HTMLButtonElement>>('closeButton');

That is how the modal moves focus to its close button when it opens. It is Angular's useRef, with the difference that the reference is declared in the markup and picked up by the class, rather than created in the class and passed down.

@let, for when you need a local

A template is not JavaScript, so there is no const. When the same value is needed more than once, @let binds it for the rest of the block:

@let selected = isSelected(topping.id);
<button
  type="button"
  class="btn btn-sm topping-chip"
  [class.btn-primary]="selected"
  [class.btn-outline-secondary]="!selected"
  [attr.aria-pressed]="selected"
  (click)="toggleTopping(topping.id)"
>
  {{ topping.name }}
  <span class="ms-1 small opacity-75">+{{ topping.price | money }}</span>
</button>

Without it, isSelected(topping.id) would be called three times for every topping chip. @let is read-only and scoped to the block it is declared in — it cannot be reassigned, which is deliberate.

What you cannot write in a template

Template expressions are a deliberately restricted subset of JavaScript. No new, no ++ or --, no bitwise operators, no chained statements with ;, and no assignment outside an event binding. Global objects are not in scope either: window, document, console and Math are all unavailable.

That is not Angular being awkward. A template is re-evaluated whenever change detection runs, so anything in it must be cheap and free of side effects. The restrictions make the whole class of "why did this run four times" bug impossible to write.

The practical consequence: calculations belong in the class, as a computed(). The product card does not compute its lowest price in the template —

readonly isPizza = computed(() => this.product().type === 'PIZZA');
readonly cheapest = computed(() => Math.min(...this.product().sizes.map((s) => s.price)));

— and the template just reads cheapest(). Both of those need Math, which a template cannot reach anyway. The restriction and the good habit point the same direction.

Templates are type checked

Worth knowing early: the expressions above are compiled and type checked against your component class. Misspell product().nmae and the build fails, naming the file and the line. A template is not a string that fails at runtime — it is code, and the compiler treats it as code.

What is next

Event bindings got one line here and deserve more: $event, the key filters like (keydown.escape) used by the modal above, and where handler logic should actually live.