What's New in Angular 18: Zoneless Change Detection, @let and Stable Control Flow
Experimental zoneless change detection in Angular 18, control flow and @defer going stable, @let from 18.1, ng-content fallback content, unified form events and redirect functions.
Version note: This post covers what Angular 18 introduced; the code targets Angular 18.
What Angular 18 Brings
Angular 18, released on May 22, 2024, is where the seeds planted over the last two versions start to bear fruit. Signals had settled into every corner of the component. Now Angular can use that knowledge to run without Zone.js, experimentally for now. Control flow and @defer, which we met in developer preview in the previous version, are now stable. And 18.1, released on July 10, 2024, added local template variables with @let.
Alongside those we will look at three smaller changes that are handy day to day: default content for ng-content, a single event stream for forms, and route redirects written as functions.
Who Updated the Screen in Angular 12
Remember the change detection post we covered earlier. In this code we do nothing to update the screen, yet the message still shows up after two seconds:
@Component({
selector: 'app-notification',
template: `<p>{{ message }}</p>`
})
export class NotificationComponent implements OnInit {
message = '';
ngOnInit(): void {
setTimeout(() => {
this.message = 'You have a new message';
}, 2000);
}
}
Zone.js makes that happen. When the app starts, it wraps browser APIs like setTimeout, addEventListener and XHR, and whenever one of them finishes it tells Angular "something may have happened, take a look". Angular then walks the tree and checks, as we saw earlier. Convenient, but there's a price: an extra library to load, patched browser APIs, stack traces full of Zone.js frames, and an Angular that guesses without ever really knowing what changed.
Zoneless: Change Detection Without Zone.js
With Angular 18 you can try switching Zone.js off entirely. In a standalone project you change app.config.ts like this:
import { ApplicationConfig, provideExperimentalZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideExperimentalZonelessChangeDetection(),
provideRouter(routes)
]
};
New projects created with Angular 18 already have a provideZoneChangeDetection({ eventCoalescing: true }) line in this file; you replace it with the provider above. Then you remove "zone.js" from the polyfills array of the build target in angular.json, so the package never gets loaded at all.
The word "Experimental" in the function name isn't decoration. Zoneless is experimental in this version, and the API's name and behaviour may change in later releases. Factor that in before taking it to production.
So without Zone.js, how does Angular know when to update the screen? It stops guessing and waits to be told. Change detection gets scheduled when a signal read in a template changes value, when an event listener in the template or on the host runs, when the async pipe receives a new value, when markForCheck() is called, or when a view is attached or removed. Think of a doorbell system: the building manager no longer goes door to door, but walks to whichever apartment rings.
What to Watch Out for in Zoneless Mode
Run the Angular 12 example above in a zoneless app and the message never appears. setTimeout no longer notifies anyone, and a plain property changing is invisible to Angular. The fix is to make the change through a signal:
import { Component, OnInit, signal } from '@angular/core';
@Component({
selector: 'app-notification',
standalone: true,
template: `<p>{{ message() }}</p>`
})
export class NotificationComponent implements OnInit {
message = signal('');
ngOnInit(): void {
setTimeout(() => {
this.message.set('You have a new message');
}, 2000);
}
}
The moment set is called, Angular knows which component reads this signal and schedules change detection. There is a trap: you may see a plain property show up on screen by accident because some other signal was updated at the same time. Don't rely on that coincidence. If you are thinking about going zoneless, the state shown on screen needs to move to signals, the async pipe, or patterns that are OnPush-friendly. A codebase that already follows the OnPush rules we covered earlier makes the move much easier.
Even if you keep Zone.js, something changed in Angular 18. A new hybrid scheduler is on by default: in an app with Zone.js, change detection is also scheduled when a signal changes or markForCheck() is called, even if that happens outside Zone.js. So if you update a signal inside the runOutsideAngular we saw earlier, the screen still refreshes.
Control Flow and @defer Are Now Stable
@if, @for, @switch and @defer, which arrived in developer preview in Angular 17, are stable in Angular 18. The syntax hasn't changed; what changed is that the Angular team now commits to no breaking changes for these APIs.
In practice, the point where teams said "let's wait a bit longer" is behind us. This is the right time to convert your existing templates with the control flow schematic we saw earlier. *ngIf and *ngFor still work in this version and are not deprecated, but there's no reason left to pick them in new code.
Template Variables with @let
In Angular 12, to use an observable's value in several places in a template, we pulled this trick:
<ng-container *ngIf="user$ | async as user">
<h2>{{ user.firstName }} {{ user.lastName }}</h2>
<p>{{ user.address.city }}, {{ user.address.country }}</p>
</ng-container>
Here *ngIf isn't really used for a condition but to declare a variable. When the value is falsy, like 0 or an empty string, the whole block disappears. With Angular 18.1:
@let user = user$ | async;
@if (user) {
@let address = user.address;
<h2>{{ user.firstName }} {{ user.lastName }}</h2>
<p>{{ address.city }}, {{ address.country }}</p>
}
@for (item of cartItems; track item.id) {
@let lineTotal = item.price * item.quantity;
<p>
{{ item.name }}: {{ lineTotal }} USD
@if (lineTotal > 1000) { <span>(free shipping)</span> }
</p>
}
@let gives an expression a name inside the template. As the expression changes, the variable's value updates on its own. There is one rule: it can't be reassigned from the template, so you can't write something like (click)="lineTotal = 0". The variable is visible in the view where it's declared and in the blocks inside it; it can't be reached from outside, and it can't be read before the line that declares it. Think of sticking a label on a jar in the kitchen: you don't change what's inside, you just call it by name instead of saying "the third jar from the left on that counter" every time.
Status note: @let arrived in developer preview in 18.1.
Default Content for ng-content
In the content projection we covered earlier, a slot that received no content simply stayed empty:
<div class="card">
<h3><ng-content select="[card-title]"></ng-content></h3>
<ng-content></ng-content>
</div>
To show a default title, you had to add an extra input or check for content with @ContentChild. In Angular 18, whatever you write inside the ng-content tag is shown when that slot stays empty:
<div class="card">
<h3><ng-content select="[card-title]">Untitled card</ng-content></h3>
<ng-content>This card has no content yet.</ng-content>
</div>
Write <app-card><p>Body only</p></app-card> and the paragraph goes into the body while the title shows "Untitled card".
Two Additions from Forms and the Router
In the reactive forms post we covered earlier, we listened to valueChanges and statusChanges separately for value and validity, and there was no observable telling us a field had been touched or made dirty. In Angular 18 every form control has a single events stream:
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl, StatusChangeEvent, TouchedChangeEvent, ValueChangeEvent, Validators } from '@angular/forms';
export class NewsletterComponent {
email = new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email] });
constructor() {
this.email.events.pipe(takeUntilDestroyed()).subscribe(event => {
if (event instanceof ValueChangeEvent) {
console.log('Value:', event.value);
}
if (event instanceof StatusChangeEvent) {
console.log('Status:', event.status);
}
if (event instanceof TouchedChangeEvent) {
console.log('Touched:', event.touched);
}
});
}
}
On the router side, redirectTo can now be a function, not just a fixed string. In Angular 12 we could give a fixed target like redirectTo: '/products/:id'; if a decision was needed, we wrote a guard. In Angular 18:
export const routes: Routes = [
{
path: 'legacy-profile',
redirectTo: ({ queryParams }) => {
const auth = inject(AuthService);
const userId = queryParams['userId'];
if (userId) {
return `/users/${userId}`;
}
return auth.isLoggedIn() ? '/me' : '/login';
}
}
];
The function receives the route's parameters, query parameters and data, and returns a string or a UrlTree. You can use the inject() we saw earlier inside it.
Moving to Angular 18: ng update
To move a project from 17:
ng update @angular/core@18 @angular/cli@18
Angular 18 wants Node.js ^18.19.1, ^20.11.1 or ^22.0.0. On the TypeScript side, 18.0 works only with 5.4, while 18.1 and later also support 5.5. During the update you are also offered an optional migration that moves the esbuild-based builders to the new @angular/build package; if you use the webpack-based builder, that step does nothing.
Watch out for one deprecation: HttpClientModule is now deprecated, replaced by the provideHttpClient() we saw earlier. New projects get a public/ folder at the root for static files instead of src/assets/; your existing project keeps working as it is.
A Small Experiment
Create a new project with Angular 18. Replace the provideZoneChangeDetection line in app.config.ts with provideExperimentalZonelessChangeDetection() and remove zone.js from the build polyfills in angular.json. Then write the plain-property version of the notification component above and watch the message never arrive. Switch to the signal version afterwards. Finally, add a cart list to the template and compute the line totals with @let.
Even in this post we still wrote standalone: true on every component. In the next post we see why Angular 19 makes that line unnecessary, meet linkedSignal, which follows another signal but can also be changed by hand, and look at the experimental resource() API that brings async data into the signal world.