Angular Data Binding and Control Flow: @if, @for, track, and Two-Way Binding
Templates earn their keep when they start making decisions โ showing this, hiding that, repeating rows, switching layouts. Angular v17 rebuilt this layer with built-in control flow (@if, @for, @switch): faster, clearer, and requiring no imports. Youโll write the new syntax and read the legacy structural-directive syntax (*ngIf, *ngFor) for years, since a decade of codebases and tutorials use it.
This guide completes the binding model โ including two-way binding and its famous โbanana in a boxโ โ and covers the control-flow blocks with the performance details (track!) that separate smooth lists from janky ones.
The Binding Model, Completed
The components guide established the core three; hereโs the full map:
{{ total() }} <!-- interpolation: state โ text --><img [src]="url" /> <!-- property binding: state โ property --><button (click)="save()"> <!-- event binding: event โ method --><input [(ngModel)]="query" /> <!-- two-way: both directions -->Plus two specialized binding families youโll use weekly:
<!-- class and style bindings โ conditional appearance without manual classList --><div [class.active]="isActive()" [class.error]="hasError()"><div [style.width.%]="progress()">
<!-- attribute binding โ for true HTML attributes (ARIA, data-*, colspan) --><td [attr.colspan]="span()"><button [attr.aria-expanded]="open()">[class.x]="bool" toggles a single class by condition โ the declarative replacement for the classList toggling you did by hand in DOM work. The [attr.] prefix exists because most bindings target DOM properties; things that are only attributes (ARIA, colspan) need the explicit form โ a distinction youโve met before.
Two-way binding: sugar, decoded
[(ngModel)] โ brackets and parentheses, the โbanana in a boxโ โ is exactly what its punctuation says: a property binding plus an event binding, fused:
<input [(ngModel)]="query" /><!-- is precisely: --><input [ngModel]="query" (ngModelChange)="query = $event" />State flows in; user edits flow out and update the state; the loop closes. It requires importing FormsModule and shines for simple cases โ a search box, a settings toggle. Two calibrations: for real forms with validation, reactive forms replace scattered ngModels with a proper model; and the same [()] sugar works on your own components via model inputs โ any x input paired with an xChange output gets the banana box for free. Itโs a convention, not magic.
@if / @else: Conditional Rendering
@if (order(); as o) { <p>Order #{{ o.id }} โ {{ o.status }}</p>} @else if (loading()) { <app-spinner />} @else { <p>No order found.</p>}Details worth internalizing:
- Elements inside a false
@ifdonโt exist โ not hidden, absent from the DOM: no rendering cost, no event listeners, child components destroyed. Contrast[hidden]or CSS toggling, which keeps everything alive. Rule of thumb:@iffor genuinely conditional content, class/hiddentoggles for rapid show/hide of cheap content whose state should survive (form inputs mid-edit!). ascaptures the checked value โ@if (order(); as o)both tests and unwraps, killing the repeated-null-check noise (order()!.ideverywhere) and playing perfectly with TypeScript narrowing. Itโs the template cousin of pattern-matching-style guards.- Conditions are ordinary template expressions โ truthiness rules apply, with the same caution about
0and""being falsy.
@for: Lists, and Why track Is Not Optional
<ul> @for (item of items(); track item.id) { <li> {{ $index + 1 }}. {{ item.name }} <button (click)="remove(item.id)">โ</button> </li> } @empty { <li class="muted">Nothing here yet.</li> }</ul>The pieces: iterate any iterable; $index, $first, $last, $even, $odd are available inside; and @empty renders when the collection is empty โ a small block that eliminates the boilerplate @if (items().length) wrapper pattern entirely.
track is mandatory in @for, and the framework is right to insist. When the array changes, Angular must decide which DOM rows correspond to which data items. track item.id says โidentity = the id,โ enabling minimal DOM surgery: one item added โ one row created; reordered โ rows moved, not rebuilt. Get it wrong and correctness suffers, not just speed:
track item.id(stable unique key) โ the right answer whenever items have identity. Rows keep their DOM (and their state: focus, input values, animations) across updates.track $indexโ legitimate only for never-reordered, never-inserted lists. With insertion at the top, every rowโs index shifts: Angular rewrites all of them, and stateful rows (inputs!) end up wearing the wrong data โ the classic โmy form values jumped to a different rowโ bug.track item(object identity) โ works until you refresh data from the server and every object is a new reference; then the whole list rebuilds. Symptoms: flickering, lost focus, sluggish updates.
If you take one performance habit from this guide: stable ids in data, track item.id in templates. (Legacy *ngFor made tracking optional via trackBy โ its omission is the number-one performance smell in old Angular code reviews.)
A closing composition note: blocks nest freely โ @for inside @if, @switch inside @for โ and when a rowโs body grows past a few lines, that body wants to be a child component taking the item as an input.
@switch, @defer, and the Rest of the Family
@switch โ cleaner than chained @else if for discriminated states:
@switch (status()) { @case ('loading') { <app-spinner /> } @case ('error') { <app-error-panel [detail]="error()" /> } @case ('ready') { <app-results [data]="data()" /> } @default { <p>Unknown state</p> }}Pairs beautifully with union-typed state ('loading' | 'error' | 'ready') โ the template equivalent of exhaustive switching over sealed states, and a shape youโll use for every async view.
@defer โ lazy-load a template chunk (and its component code!) on a trigger:
@defer (on viewport) { <app-reviews-panel [productId]="id()" /> <!-- code loads when scrolled near -->} @placeholder { <div class="skeleton"></div>} @loading (minimum 200ms) { <app-spinner />} @error { <p>Couldn't load reviews.</p>}Triggers include on viewport, on idle, on interaction, on hover, and when condition. This is dynamic-import code splitting promoted to a template primitive โ below-the-fold widgets, heavy charts, and rarely-opened panels stop taxing initial load, with placeholder/loading/error states declared inline. Itโs one of modern Angularโs best features and deserves a place in your default toolkit, not your advanced one.
Reading Legacy: The Structural-Directive Rosetta Stone
You will maintain code written before v17. The translation table:
<!-- legacy --> <!-- modern --><div *ngIf="user; else empty">...</div> @if (user) { ... } @else { ... }<ng-template #empty>...</ng-template>
<li *ngFor="let item of items; @for (item of items; trackBy: trackById; let i = index"> track item.id) { ... }
<div [ngSwitch]="status"> @switch (status) { <p *ngSwitchCase="'a'">A</p> @case ('a') { <p>A</p> } <p *ngSwitchDefault>?</p> @default { <p>?</p> }</div> }The * prefix marked structural directives โ directives that add/remove DOM, with the asterisk as sugar over <ng-template> wrappers (which is why the legacy else clause needs an explicit template reference). They required importing CommonModule/NgIf/NgFor; the new blocks are template-language natives. Migration is mechanical (ng generate @angular/core:control-flow automates it), but mixed codebases are the norm โ fluency in both directions is a genuine job skill for the next several years.
Composing the Blocks: An Async List, Complete
The blocks earn their keep composed. Hereโs the full pattern for the most common UI in existence โ a filterable, loadable list โ with every state handled:
<input [(ngModel)]="query" placeholder="Filter ordersโฆ" />
@if (orders.isLoading()) { <app-skeleton-rows [count]="5" />} @else if (orders.error()) { <app-error-panel (retry)="orders.reload()" />} @else { <table> @for (order of visible(); track order.id) { <tr [class.overdue]="order.dueDate < today" [class.selected]="order.id === selectedId()"> <td>{{ order.id }}</td> <td>{{ order.customer }}</td> <td> @switch (order.status) { @case ('pending') { <span class="badge warn">Pending</span> } @case ('shipped') { <span class="badge ok">Shipped</span> } @default { <span class="badge">{{ order.status }}</span> } } </td> </tr> } @empty { <tr><td colspan="3"> @if (query()) { No orders match "{{ query() }}". } @else { No orders yet. } </td></tr> } </table>}Read the states it renders: loading (skeleton), failed (retriable error), empty-because-filtered vs genuinely-empty (different messages โ a detail users notice), and the happy path with per-row conditional styling and per-cell status switching. visible() is a computed filtering by query โ the filter logic lives in the class, not the template, and track order.id keeps row updates surgical as filtering changes the array. This template shape โ outer async @if-chain, inner @for with @empty, @switch for enum display โ is worth having in muscle memory; a large fraction of every data-driven app is this pattern with different nouns.
Frequently Asked Questions
Can I call methods in bindings โ {{ formatPrice(item) }}? It works, but every change-detection pass re-runs it โ fine for trivial functions, a trap for expensive ones. Prefer computed signals (cached, re-run only when dependencies change) or pipes (memoized by input). The general rule: bindings should be cheap reads.
Why does my @if-wrapped form lose its values? Because @if destroys and recreates โ thatโs its contract. State that must survive visibility toggles belongs in the class (or use a CSS-based toggle for the quick-show/hide case).
@let โ whatโs that? A newer block for naming template-local values: @let vat = total() * 0.18; then use vat below. It kills repeated long expressions; use it for readability, not for smuggling logic into templates.
How do I iterate object entries? Convert in the class โ Object.entries(obj) as ever โ exposed via a computed. (Legacy code used the keyvalue pipe; it still works.)
Is @defer a replacement for route-level lazy loading? Complementary โ route-level splitting chunks by page; @defer chunks within a page. Big apps use both.
Binding Edge Cases Worth Knowing Early
Four small behaviors that each cost someone an afternoon: Event binding $event types โ on DOM events itโs the raw event (e.target needs casting; prefer template refs: #inp then inp.value); on component outputs itโs whatever the child emitted โ one name, context-dependent meaning. Bindings run in order of neither appearance nor alphabet โ writing code that depends on [a] binding before [b] on the same element is undefined-behavior territory; if two inputs are interdependent, the child should derive rather than assume arrival order. [class] and [style] full-object bindings coexist with single bindings ([class.active]) but the single form wins conflicts โ mixing both on one element for the same class reads as a bug even when it works. Interpolation stringifies via toString โ {{ user }} printing [object Object] means you bound the object, not a field; and null/undefined interpolate as empty string (mercifully), while {{ obj.missing.deep }} still throws โ optional chaining works in templates and is the idiomatic guard.
Self-Check
- A list of editable rows uses
track $index. The user adds a row at the top and existing rowsโ input values shift down one. Explain the mechanism. <div [hidden]="!showPanel">vs@if (showPanel)โ name a scenario where each is the right choice.- Decode
[(ngModel)]="email"into its two constituent bindings. - A dashboardโs below-the-fold analytics chart weighs 300KB of JS. Which control-flow block fixes the initial-load cost, and with what trigger?
- In legacy code you see
*ngForwith notrackBy. What symptoms would confirm itโs causing harm?
(Answers: index-tracking told Angular โrow identity = position,โ so the insertion re-bound every rowโs data to shifted indexes while DOM state (inputs) stayed put โ identity and state disagree. [hidden] for cheap content toggled rapidly whose state must survive (a filter panel mid-edit); @if for expensive or truly conditional content. [ngModel]="email" + (ngModelChange)="email = $event". @defer (on viewport) with a @placeholder skeleton. Full-list DOM rebuilds on every data refresh: flicker, lost focus/selection, GC churn โ visible in DevTools as every row node replaced.)
Next: Directives โ attribute directives, host bindings, and building your own reusable element behaviors.