1. @if / @else if / @else
Conditional rendering, block-scoped and readable top to bottom — no more prefixing an attribute with an asterisk.
@if (order().status === 'delivered') {
<p class="status status--success">Delivered {{ order().deliveredAt | date }}</p>
} @else if (order().status === 'shipped') {
<p class="status status--info">On its way — tracking #{{ order().trackingNumber }}</p>
} @else if (order().status === 'cancelled') {
<p class="status status--error">Cancelled</p>
} @else {
<p class="status status--pending">Processing</p>
}
You can also capture the condition's value with as — handy when the
condition is an expensive call or a signal you want to read once per block:
@if (currentUser(); as user) {
<p>Welcome back, {{ user.name }}</p>
}
2. @for, Track Expressions & Performance
@for requires a track expression — not optional the way
trackBy was with *ngFor. It tells Angular how to recognize
"this is the same item" across re-renders, even if its position in the array changed.
@for (product of products(); track product.id) {
<app-product-card [product]="product" />
} @empty {
<p>No products match your filters.</p>
}
track product.id and not track $index
With a stable ID, reordering, filtering, or sorting the array lets Angular match existing DOM nodes and components to their (possibly moved) items — no destroy/recreate, form state and focus survive. With $index, position is treated as identity: sort the list and Angular thinks every item "changed," destroying and recreating components it didn't need to. Reach for $index only when items genuinely have no stable identity.
@for also exposes contextual variables you'll use constantly:
@for (item of items(); track item.id; let i = $index, isLast = $last, count = $count) {
<li [class.is-last]="isLast">
{{ i + 1 }} of {{ count }}: {{ item.name }}
</li>
}
3. @switch / @case / @default
For more than two or three branches on the same value, @switch reads
better than a chain of @else if:
@switch (user().role) {
@case ('admin') {
<app-badge color="red">Admin</app-badge>
}
@case ('editor') {
<app-badge color="blue">Editor</app-badge>
}
@case ('viewer') {
<app-badge color="gray">Viewer</app-badge>
}
@default {
<app-badge color="gray">Unknown</app-badge>
}
}
Unlike JavaScript's switch, there's no fallthrough to worry about — each
@case block is independent, so you never need a stray break.
4. @let for Local Template Variables
@let declares a template-scoped variable — useful for naming an expression
you'd otherwise repeat, without introducing a whole @if ... as block just to
get a local name.
@let itemCount = cartItems().length;
@let isEmpty = itemCount === 0;
<p>{{ itemCount }} item{{ itemCount === 1 ? '' : 's' }} in cart</p>
@if (isEmpty) {
<p>Your cart is empty.</p>
}
@let variables are read-only and scoped to the template block they're
declared in — you can't reassign one, and a value declared inside an @if
block isn't visible outside it.
5. Conditional Rendering & Empty-State Patterns
Combining what you've learned so far, a realistic loading/empty/error/success template
looks like this — a pattern you'll see again, built on signals instead of raw booleans,
once you meet resource() in Week 16:
@if (isLoading()) {
<app-spinner />
} @else if (error()) {
<p class="error">{{ error() }}</p>
} @else {
@for (result of results(); track result.id) {
<app-result-row [result]="result" />
} @empty {
<p>No results for "{{ query() }}".</p>
}
}
Notice @empty only fires when the list is genuinely empty — it's distinct
from the loading and error states above it, which is exactly the distinction a real
search UI needs to make.
6. Migrating from *ngIf/*ngFor
Side by side, the mapping is direct:
<div *ngIf="user; else guestTpl">
Welcome, {{ user.name }}
</div>
<ng-template #guestTpl>
<div>Please log in</div>
</ng-template>
<li *ngFor="let item of items; trackBy: trackById">
{{ item.name }}
</li>
@if (user) {
<div>Welcome, {{ user.name }}</div>
} @else {
<div>Please log in</div>
}
@for (item of items; track item.id) {
<li>{{ item.name }}</li>
}
The Angular CLI ships a schematic that does this conversion automatically across an entire project:
ng generate @angular/core:control-flow
It's worth running once on a real project and reading the diff — you'll see every pattern from this lesson applied at scale, which cements the mapping far better than flashcards would.
7. Hands-on Exercise
Rebuild a data table using only the new control-flow syntax
Build a sortable, filterable table over a list of employees — no *ngIf or *ngFor anywhere in it.
Requirements:
- A signal holding an array of
{ id, name, department, salary }records (at least 10 fake entries). - A text input bound to a
filterTextsignal; use acomputed()to derive the filtered list (case-insensitive match onnameordepartment). - Three sort buttons (by name, department, salary) that toggle a
sortKeysignal; anothercomputed()derives the sorted-and-filtered list from that. - Render rows with
@for, tracked byid, showing an@emptyblock ("No employees match.") when the filtered list is empty. - Use
@switchto render a colored badge based on a computedsalaryBand('junior' / 'mid' / 'senior') per row. - Use at least one
@letto avoid repeating an expression in the template.
Chain your computed signals: filtered reads the raw list and filterText; sorted reads filtered and sortKey. Keeping each computed focused on one transformation makes the whole pipeline easier to reason about than one giant computed doing everything at once.
Part 2: Loading/error state with @if...as, and a hand migration
Part 1 never actually required a plain @if, an as-binding, or Section 6's migration. This part adds both.
Requirements:
- Full loading/error/success pattern (Sections 1 & 5): add
isLoadinganderrorsignals, simulate an async fetch of the employee list on init (asetTimeoutis fine), and wrap the whole table in@if (isLoading()) { ... } @else if (error()) { ... } @else { ... }— the exact pattern from Section 5, built with signals instead of a resource. as-binding: add a "select a row" feature — clicking a row sets aselectedEmployeeIdsignal. In a details panel, use@if (selectedEmployee(); as emp)(whereselectedEmployeeis a computed lookup) to read it once and render several of its fields without recomputing the lookup for each one.- Hand migration (Section 6): below is a small legacy snippet. Convert it to the new control-flow syntax by hand — no schematic — matching it feature-for-feature, including the
#guestTplfallback.
<div *ngIf="selectedEmployee; else noSelectionTpl">
Editing {{ selectedEmployee.name }}
</div>
<ng-template #noSelectionTpl>
<div>Select a row to edit</div>
</ng-template>
<span *ngFor="let dept of departments; trackBy: trackByName; let last = last">
{{ dept }}{{ last ? '' : ', ' }}
</span>
let last = last maps to @for's built-in $last context variable from Section 2 — no separate trackByName function needed once you're tracking by the department name directly.
8. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why does @for require a track expression, when *ngFor's trackBy was optional?
Why does @for require a track expression, when *ngFor's trackBy was optional?
Making it mandatory forces you to think about item identity up front instead of accidentally shipping the slower default (identity-based tracking, which behaves like tracking by index for object literals). It's a deliberate API design choice to prevent a whole class of easy-to-miss performance bugs.
Q2
What actually goes wrong if you use track $index on a list the user can reorder?
What actually goes wrong if you use track $index on a list the user can reorder?
Angular treats "position 3" as the identity, not the item itself. After a reorder, it thinks every position holds a different item than before (even ones that just moved), so it destroys and recreates the components at each position instead of moving them — losing any local component state, focus, or in-progress animations along the way, and doing unnecessary work.
Q3
What's the scope of a variable declared with @let inside an @if block?
What's the scope of a variable declared with @let inside an @if block?
Just that @if block — it isn't visible outside it, the same way a let declared inside a JavaScript if { } block wouldn't be visible after the closing brace. @let variables are also read-only; you can't reassign one after declaring it.
Q4
In the loading/error/empty pattern from Section 5, why is @empty not enough on its own to handle all three states?
In the loading/error/empty pattern from Section 5, why is @empty not enough on its own to handle all three states?
@empty only knows about one thing — whether the array passed to @for has zero items. It can't distinguish "still loading" or "the request failed" from "the request succeeded and returned nothing," because by the time @for runs, the array is either populated or it isn't. Loading and error are separate booleans/signals that have to be checked before you even reach the @for.