In May 2024, the Angular team released version 18 of its framework. For the first time, it offers an official way to work without Zone.js, albeit only experimentally. There are also some really nice improvements in existing API such as the router.
In this article I will discuss the new features.
📂 Source Code
Zone-less
Since its early days, Angular has used the Zone.js library to figure out when change detection needs to check the component tree for changes. The idea behind it is simple: in a JavaScript application, only event handlers can change bound data. So the goal is to figure out when an event handler has been executed. To do this, Zone.js hooks into all browser objects: HTMLInputElement, Promise, and XmlHttpRequest are just a few examples. This approach, which uses the dynamic nature of JavaScript to change existing objects, is called monkey patching.
Even if this approach usually works well, it does lead to problems. For example, bugs in this area are difficult to diagnose and since not every event handler necessarily changes bound data, change detection runs too frequently. If you develop reusable web components that hide implementation details such as the use of Angular, the consumer still has to use them with a specific Zone.js version.
As of version 18, the framework now also supports a data binding mode that does not require Zone.js. It is experimental for now so that the Angular team can collect feedback on the new approach. To enable this mode, the provideExperimentalZonelessChangeDetection function must be used when bootstrapping the application:
export const appConfig: ApplicationConfig = {
providers: [
provideExperimentalZonelessChangeDetection(),
[…]
]
}
Since Zone.js no longer triggers change detection, Angular needs other triggers. These are the ones that are also used in the OnPush data binding mode:
- An observable bound with the async pipe publishes a new value.
- A bound signal publishes a new value.
- The object reference of an input changes.
- A UI event with a bound event handler occurs (e.g. click).
- The application triggers the change detection manually.
This means that those who have consistently used OnPush in the past can now switch to Zone-less with relative ease. In cases where this is not easily possible, the application can remain Zone.js-based without any concerns. The Angular team assumes that not every existing application will be switched to Zone-less and therefore continues to support Zone.js.
However, for new applications, it is advisable to work without Zone.js as soon as this new mode is no longer experimental. The planned Signal Components will make Zone-less applications easier in the future, as they automatically meet the requirements through the consistent use of Signals.
After switching to Zone-less, the reference to Zone.js can also be removed from the angular.json . By removing this library, the bundles in production mode become about 11 KB smaller.
Coalescing Zone
For new applications, the Angular CLI still uses Zone.js. What's new is that it now generates code that enables event coalescing by default:
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
[…]
]
};
Event coalescing means that Zone.js only takes action once for events that occur immediately after each other. The Angular team gives an example of several click handlers that are triggered one after the other due to event bubbling.
Modern Angular
Update your Angular knowledge with our Modern Angular Workshop (Englisch Version, German Version)
New Features in Router Redirects
If a guard wants to redirect to another route, it returns the UrlTree of this route. Unlike the navigate method, however, this approach does not allow the specification of options for detailed control of the router's behavior. For example, the application cannot specify whether the current entry in the browser history is to be overwritten or whether parameters that should not appear in the URL are to be passed.
To use this option, a guard can now also return a RedirectCommand. In addition to the UrlTree, this RedirectCommand receives an object of type NavigationBehaviorOptions that controls the desired behavior:
export function isAuth(destination: ActivatedRouteSnapshot) {
const router = inject(Router);
const auth = inject(AuthService);
if (auth.isAuth()) {
return true;
}
const afterLoginRedirect = destination.url.join('/');
const urlTree = router.parseUrl('/login');
return new RedirectCommand(urlTree, {
skipLocationChange: true,
state: {
needsLogin: true,
afterLoginRedirect: afterLoginRedirect
} as RedirectToLoginState,
});
}
export const routes: Routes = [
{
path: '',
redirectTo: 'products',
pathMatch: 'full'
},
{
path: 'products',
component: ProductListComponent,
},
{
path: 'login',
component: LoginComponent,
},
{
path: 'products/:id',
component: ProductDetailComponent,
canActivate: [isAuth]
},
{
path: 'error',
component: ErrorComponent
}
];
The skipLocationChange property specifies that the route change should not appear in the browser history and the specified state contains values that are to be passed to the component to be activated, but should not appear in the URL. Further properties provided by NavigationBehaviorOptions can be found here.
For the sake of completeness, the following listing shows the addressed component that reads the passed data:
@Component({ … })
export class LoginComponent {
router = inject(Router);
auth = inject(AuthService);
state: RedirectToLoginState | undefined;
constructor() {
const nav = this.router.getCurrentNavigation();
if (nav?.extras.state) {
this.state = nav?.extras.state as RedirectToLoginState;
}
}
logout() {
this.auth.logout();
}
login() {
this.auth.login('John');
if (this.state?.afterLoginRedirect) {
this.router.navigateByUrl(this.state?.afterLoginRedirect);
}
}
}
The use of a RedirectCommand is now also supported by the optional feature withNavigationErrorHandler:
export function handleNavError(error: NavigationError) {
console.log('error', error);
const router = inject(Router);
const urlTree = router.parseUrl('/error')
return new RedirectCommand(urlTree, {
state: {
error
}
})
}
export const appConfig: ApplicationConfig = {
providers: [
[…]
provideRouter(
routes,
withComponentInputBinding(),
withViewTransitions(),
withNavigationErrorHandler(handleNavError),
),
]
};
Another new feature in terms of redirects concerns the redirectTo property in the router configuration. Until now, you could refer to the name of another route. Now this property also accepts a function that takes care of the redirection programmatically:
export const routes: Routes = [
{
path: '',
redirectTo: () => {
const router = inject(Router);
// return 'products' // Alternative
return router.parseUrl('/products');
},
pathMatch: 'full'
},
[…],
];
The return value of this function is either a UrlTree or a string with the path of the desired route.
Standard Content for Content Projection
The ng-content element used as a target for content projection can now have default content. This is the content that Angular displays if the caller does not pass any other content:
<div class="pl-10 mb-20">
<ng-content>
<b>Book today to get 5% discount!</b>
</ng-content>
</div>
Events for Reactive Forms
The AbstractControl, which acts as a base class for FormControl, FormGroup, and others, now has an events property. This Observable informs about numerous state changes:
export class ProductDetailComponent implements OnChanges {
[…]
formControl = new FormControl<number>(1);
[…]
constructor() {
this.formControl.events.subscribe(e => {
console.log('e', e);
});
}
[…]
}
It publishes the individual events as objects of type ControlEvent. ControlEvent is an abstract base class with the following implementations:
- FormResetEvent
- FormSubmittedEvent
- PristineChangeEvent
- StatusChangeEvent
- TouchedChangeEvent
- ValueChangeEvent
Event Replay for SSR
By using server-side rendering, the requested page is displayed to the user more quickly. The browser then begins loading the JavaScript bundles that make the page interactive, also known as hydration:
Here, FMP stands for First Meaningful Paint and TTI for Time to Interactive.
The time span between FMP and TTI, also known as the "uncanny valley", requires special attention. The user is already shown the page here. However, the JavaScript that reacts to user interactions has not yet been loaded. Clicks and other interactions are therefore ignored.
To prevent this, Angular now offers an event replay that records the interactions in the uncanny valley and then repeats them. This is made possible by a minimal script that the browser initially loads together with the pre-rendered page.
Event Replay comes as an optional feature for provideClientHydration and is activated with the withEventReplay function:
export const appConfig: ApplicationConfig = {
providers: [
[…],
provideClientHydration(
withEventReplay()
)
]
};
The implementation of Event Replay has been proven at Google for some time. It comes from Google's internal framework Wiz Wiz, which is known for its capabilities in the areas of SSR and hydration and is therefore used for performance-critical public solutions.
At this year's ng-conf, the Angular team announced that the Angular and Wiz teams will be working more closely together in the future. In a first step, the Wiz team will use Angular's Signals, while Angular has adopted the tried and tested event replay options from the Wiz team.
Automatic TransferState for HTTP Requests
When using SSR, the HttpClient caches the results of HTTP requests made on the server side so that the requests do not have to be executed again in the browser. An interceptor delegating to the Transfer State API is used for this. The Transfer API embeds the cached data in the markup of the pre-rendered page and the HttpClient accesses it in the browser.
This implementation now also takes into account that different URLs can be used on the server side than in the browser. For this purpose, an object can be configured that maps internal server-side URLs to the URLs for use in the browser. The following example from respective pull request illustrates the configuration of this object:
// in app.server.config.ts
{
provide: HTTP_TRANSFER_CACHE_ORIGIN_MAP,
useValue: {
'http://internal-domain:80': 'https://external-domain:443'
}
}
// Alternative usage with dynamic values
// (depending on stage or prod environments)
{
provide: HTTP_TRANSFER_CACHE_ORIGIN_MAP,
useFactory: () => {
const config = inject(ConfigService);
return {
[config.internalOrigin]: [config.externalOrigin],
};
}
}
HttpClient no longer automatically places the results of server-side HTTP requests that contain an Authorization or Proxy-Authorization header in the Transfer State. If you want to continue to cache such results in the future, use the new includeRequestsWithAuthHeaders property:
withHttpTransferCache({
includeRequestsWithAuthHeaders: true,
})
DevTools and Hydration
If desired, the Angular DevTools now show which components have already been hydrated. To do this, activate the Show hydration overlays option at the bottom right:
This option displays all hydrated components with a blue-transparent overlay and a water drop icon in the top right.
Migration to new ApplicationBuilder
The new ApplicationBuilder was introduced with Angular 17 and automatically set up for new Angular applications. Since it is based on modern technologies such as esbuild, it is much faster than the original webpack-based builder, which Angular still supports. In initial tests, I was able to see an acceleration of a factor of 3 to 4. The ApplicationBuilder also comes with convenient support for SSR.
When updating to Angular 18, the CLI now suggests converting existing applications to the ApplicationBuilder:
Since the CLI team has invested a lot of effort in feature parity between the classic and the new implementation, this change should work well in most cases and speed up build times dramatically.
Further Innovations
In addition to the new features already described, there are some smaller updates and improvements:
- @defer also works in npm packages.
- A new token _HOST_TAGNAME points to the tag name of the current component.
- Angular I18N now works with Hydration.
- The modules HttpClientModule, HttpClientXsrfModule, and HttpClientJsonpModule as well as the HttpClientTestingModule are now deprecated. The corresponding standalone APIs such as provideHttpClient are used as replacements. A schematic automatically takes care of this change when updating to Angular 18.
- For new projects, the CLI now sets up a public folder instead of an assets folder. With this, the Angular team wants to move closer to a common practice in the world of web development.
- For Zone-less applications, the CLI no longer converts async and await to promises. This is necessary when using Zone.js because, other than async and await, Promises can be monkey-patched.
- The ApplicationBuilder now caches intermediate results. This can dramatically speed up subsequent builds.
What's next? Modern Angular!
Modern Angular has even more to offer:
- Reactive data flows with Signals
- Updated APIs for the Router and HttpClient
- Standalone Components, Directives, and Pipes
- The new built-in control flow and @defer
- Options for automatic migrations
- esbuild, SSR, and Hydration
In our free eBook, we cover these topics in 14 chapters. Download it now:
Summary
After several releases with many new features, Angular 18 focuses primarily on rounding corners. There are new options for router redirects, default values for content projection, event replay and improvements for using the Transfer State API for HTTP requests.
In addition, numerous bugs have been addressed and the performance of the new ApplicationBuilder has been improved through caching. Besides this, there also a preview of Zone-less Angular that will pave the way for the future of the framework's change detection.