What's New in Angular 17: @if, @for, @defer and the New Application Builder
The @if, @for and @switch control flow, @defer and its triggers, the esbuild and Vite based application builder and standalone ng new in Angular 17, compared with Angular 12.
Version note: This post covers what Angular 17 introduced; the code targets Angular 17.
Templates Get a New Look
Angular 17, released on November 8, 2023, brought the change anyone looking at an Angular template for the first time would notice most. The flow control we wrote for years with *ngIf, *ngFor and [ngSwitch] is now part of the template syntax itself: @if, @for, @switch. Next to them comes the @defer block, which loads a component only when it's needed.
Outside the template there are two more big changes. New projects now ship with the esbuild and Vite based application builder, and ng new creates a standalone project without NgModule by default. Signal-based inputs and outputs are a separate story; we will get to them in the next post.
From *ngIf to @if
In the built-in directives post we covered earlier, conditional rendering looked like this:
<div *ngIf="user; else guest">
<p>Welcome, {{ user.name }}</p>
</div>
<ng-template #guest>
<p>You are not signed in.</p>
</ng-template>
The else branch needed its own ng-template with a name. Adding a third case meant nesting *ngIfs. In Angular 17 the same thing reads:
@if (user) {
<p>Welcome, {{ user.name }}</p>
} @else if (isLoading) {
<p>Loading...</p>
} @else {
<p>You are not signed in.</p>
}
It reads just like an if in TypeScript. @else if is supported directly, and you don't need a wrapping div or ng-container. One more detail: these blocks are not directives but part of the template compiler. You can use them in a standalone component without importing NgIf or CommonModule.
The familiar as pattern for observables is still there: write @if (user$ | async; as user) { ... } and you can use user inside the block.
From *ngFor and trackBy to @for and track
When rendering lists in Angular 12, you had to remember to add trackBy for performance:
<li *ngFor="let task of tasks; let i = index; let last = last; trackBy: trackById">
{{ i + 1 }}. {{ task.title }} <span *ngIf="last">(last)</span>
</li>
trackById(index: number, task: Task): number {
return task.id;
}
In most projects that function was never written, and nobody noticed. Not until the list grew and the whole DOM got redrawn on every refresh. In Angular 17:
<ul>
@for (task of tasks; track task.id) {
<li>
{{ $index + 1 }}. {{ task.title }}
@if ($last) { <span>(last)</span> }
</li>
} @empty {
<li>No tasks yet.</li>
}
</ul>
track is now mandatory. Leave it out and the template doesn't compile. You don't need a separate function either; you just say which field is the identity. Picture a hotel front desk: if you track guests by room number, when someone checks out you only clear that room. If you don't know who is who, the safest move is to check everyone out and seat them again. track is that room number. For a simple list of strings with no identity, track $index works too.
$index, $first, $last, $even, $odd and $count are available inside the block out of the box; you don't have to alias them with let i = index. When the list is empty, the @empty block runs. In Angular 12 we wrote a separate *ngIf="tasks.length === 0" next to the list for that.
@switch and Migrating Existing Templates
The Angular 12 pattern for showing different content per state:
<div [ngSwitch]="task.status">
<span *ngSwitchCase="'done'">Done</span>
<span *ngSwitchCase="'in-progress'">In progress</span>
<span *ngSwitchDefault>Waiting</span>
</div>
In Angular 17:
@switch (task.status) {
@case ('done') { <span>Done</span> }
@case ('in-progress') { <span>In progress</span> }
@default { <span>Waiting</span> }
}
You don't need a wrapper element to hold the switch, and there is no fall-through from one case to the next like in JavaScript; each @case runs its own block and stops.
You don't have to convert dozens of templates by hand. Angular ships a schematic for it:
ng generate @angular/core:control-flow
The command rewrites the *ngIf, *ngFor and ngSwitch usages in your project into the new blocks. Always review the diff, and in lists that had no trackBy, check what was picked as track. Status note: in Angular 17 the new control flow is in developer preview. The old directives keep working, and both can live side by side in the same project, even in the same template.
@defer: Loading Views When They Are Needed
In Angular 12, lazy loading happened almost exclusively at the route level. If you wanted to load a heavy chart component inside a page later on, you did it by hand:
async showChart(): Promise<void> {
const { SalesChartComponent } = await import('./sales-chart/sales-chart.component');
const factory = this.resolver.resolveComponentFactory(SalesChartComponent);
this.container.createComponent(factory);
}
A dynamic import, a factory, a container reference, and keeping track of the modules the component needs. Most teams skipped the hassle and left the chart in the main bundle. In Angular 17 the template handles it:
@defer (on viewport) {
<app-sales-chart [data]="sales" />
} @placeholder {
<div class="chart-box">The chart will appear here</div>
} @loading (minimum 500ms) {
<p>Loading chart...</p>
} @error {
<p>The chart could not be loaded.</p>
}
The compiler splits the component inside the @defer block into its own JavaScript file. When the page opens, the user sees the @placeholder content. As soon as that area scrolls into view the file is downloaded, @loading is shown in the meantime, and @error takes over if something goes wrong. minimum 500ms keeps the loading message from flashing on screen for a split second.
There are two conditions. The deferred component must be standalone, and it must not be referenced from anywhere else in the same file, for example through a @ViewChild. Otherwise the compiler can't split it out, and the component lands in the main bundle as usual. It's like not putting dessert on the table at the start of the meal: it comes from the kitchen when you ask, but if you already put it on the main course plate, there is no point bringing it separately.
Triggers: When Should It Load?
The part in parentheses after @defer says when loading starts. If you write nothing, the default is on idle, meaning when the browser has nothing else to do. Other options:
@defer (on interaction; prefetch on idle) {
<app-comment-list [postId]="postId" />
} @placeholder {
<button>Show comments</button>
}
@defer (when showDetails) {
<app-product-details [product]="product" />
}
@defer (on timer(2s)) {
<app-newsletter-box />
}
on interaction loads when the user clicks the placeholder or interacts with it from the keyboard. prefetch on idle downloads the file ahead of time while the browser is idle, so rendering is instant on click. on hover fires when the mouse moves over it, on immediate right after the page renders, and on timer after the given duration.
when watches a condition. Careful: once it becomes true and the content loads, the content is not removed even if the condition turns false again. If you want show/hide behaviour, use an @if inside. Without a template reference, on viewport, on interaction and on hover watch the placeholder, so the placeholder needs a single root element. @defer is in developer preview in this version too.
The New Application Builder: esbuild and Vite
In an Angular 12 project, the build target in angular.json pointed at the webpack-based builder: @angular-devkit/build-angular:browser. In a new project created with Angular 17 it looks like this:
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/task-board",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json"
}
}
Under the hood, esbuild handles the build and Vite's dev server runs behind ng serve. The speed we tried as a preview in the previous version is now the default. Note that the entry point option is called browser instead of main. The output also no longer goes straight into dist/task-board but into the dist/task-board/browser folder. In the deploy steps we covered earlier, update the folder you copy to the server accordingly.
Existing projects don't switch builders on their own; the webpack-based builder keeps working. If you want to move over, you update the builder line and a few options by hand.
ng new Now Starts Standalone
In Angular 12, the first thing ng new did was create an app.module.ts. In Angular 17 the same command sets up the standalone structure we saw earlier by default, with routing on from the start:
// src/main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err));
// src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes)]
};
// src/app/app.component.ts
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, RouterOutlet],
templateUrl: './app.component.html',
styleUrl: './app.component.css'
})
export class AppComponent {
title = 'task-board';
}
App-wide providers now live in app.config.ts and routes in app.routes.ts. A small but nice detail: for a single stylesheet you can write styleUrl instead of an array. Setup also asks whether you want server-side rendering; you can answer up front with the --ssr flag. If you need an NgModule-based project, ng new task-board --no-standalone still works.
Moving to Angular 17: ng update
To move a project from 16 to the next major:
ng update @angular/core@17 @angular/cli@17
Angular 17 wants Node.js ^18.13.0 or ^20.9.0; Node 16 support is gone. TypeScript must be at least 5.2, and as of 17.3 versions up to 5.4 are supported. zone.js moves up to ~0.14.0.
The signals we saw in developer preview in the previous version changed status too: signal and computed are stable in Angular 17, while effect is still in developer preview. The signal mutate() method was removed; if you used it in 16, switch to update(). Moving to the new control flow is not part of the update itself; run the schematic whenever you are ready.
A Small Experiment
Write a small task list component that uses *ngIf, an *ngFor with trackBy, and [ngSwitch]. Commit first, then run ng generate @angular/core:control-flow and go through the resulting diff line by line. Next, put a separate standalone component below the list, pretend it's heavy, wrap it in @defer (on viewport), and leave a placeholder at the bottom of the page. Open the browser's Network tab and watch a new JavaScript file arrive as you scroll down.
The templates got a makeover, but component inputs, outputs and @ViewChilds are still written with decorators. In the next post we look at the signal-based input(), model(), output() and signal queries that arrived in Angular 17's minor releases.