TL;DR: Angular 18 introduced zoneless change detection, an experimental feature to replace Zone.js. It improves performance by manually controlling when change detection occurs, reducing unnecessary cycles, and enhancing app efficiency.
Change detection is the process by which Angular ensures that the user interface (UI) reflects the current state of the application. Traditionally, Angular used Zone.js to track asynchronous operations and trigger change detection when necessary.
Angular 18 has introduced an exciting experimental feature: zoneless change detection. This innovation promises to enhance performance by eliminating the need for Zone.js, which has been a cornerstone of Angular’s change detection mechanism. In this blog, we’ll look at the concepts, benefits, and implementation of zoneless change detection.
What is change detection in Angular?
In Angular, change detection refers to the process through which the framework monitors an app’s state and updates the view accordingly. Angular uses Zone.js to track asynchronous tasks such as HTTP requests or timers, and it triggers change detection based on these operations. Zone.js “monkey-patches” browser APIs like setTimeout and addEventListener, allowing Angular to recognize when asynchronous events happen.
Angular organizes components in a tree structure, with the root being the AppComponent, and all other components branching out as children. Each component has its own change detector. When a change occurs, Angular checks each component in a bottom-up fashion, updating only the necessary parts of the view.
When an event like a user interaction or an HTTP response occurs, Angular starts a tick cycle. It begins at the root of the change detection tree and compares the current data of each component with its previous state. If a difference is found, the view is updated. Otherwise, Angular moves on to the next component without making changes.
Angular provides the following two change detection strategies:
Default strategy: Angular checks all components in the tree when any change happens.
OnPush strategy: Angular checks the component only if its inputs have changed, or if an event is emitted inside the component. This improves performance, especially in large apps, by limiting unnecessary checks.
How does change detection work in Angular?
Let’s create an Angular app and see zone-based change detection in action.
Run the following command to create an Angular app.
ng new zoneless-app
Once the project is created, open the src\app\app.component.ts file and put the following code into it.
import { Component, inject, OnInit, OnDestroy } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { AsyncPipe } from '@angular/common';
import { map, catchError, of } from 'rxjs';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, AsyncPipe],
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent implements OnInit, OnDestroy {
private readonly httpClient = inject(HttpClient);
private intervalId: any; // For managing the setInterval lifecycle.
currentCount = 0;
timer = 0;
movies$ = this.fetchMovieData();
incrementCount() {
this.currentCount++;
}
ngOnInit() {
this.intervalId = setInterval(() => {
this.timer++;
}, 1000);
}
ngOnDestroy() {
if (this.intervalId) {
clearInterval(this.intervalId); // Clear the interval on destroy.
}
}
fetchMovieData() {
return this.httpClient
.get<any[]>('https://dummyapi.online/api/movies')
.pipe(
map((movieList) => movieList.slice(0, 5)),
catchError((error) => {
console.error('Error fetching movies', error);
return of([]); // return empty array in case of error.
})
);
}
}
The incrementCount method increases the currentCount property by 1. In the ngOnInit lifecycle hook, we use the setInterval function to increment the timer property every second. The fetchMovieData method makes an HTTP GET request to retrieve movie data from a publicly available API. It utilizes the map operator to transform the response, slicing the movie list to return only the first five movies. The result is observable.
Open the src\app\app.component.html file and paste the following code into it.
<h3>Click Event</h3>
<p>Current count: {{ currentCount }}</p>
<button class="btn btn-primary" (click)="incrementCount()">Click me</button>
<h3>Async timer</h3>
<p>Timer value: {{ timer }}</p>
<h3>Fetch data from HTTP call</h3>
@for (movie of movies$ | async; track movie.id) {
<p>{{ movie.movie }}</p>
}
We have a button that, when clicked, triggers the incrementCount method in the component class to increase the currentCount. Additionally, the current value of the timer property, which updates every second, will be displayed. We also use the async pipe on the movies$ observable and loop through the data to display the movie names.
To run the app, use the command ng serve. Once the app is running, you’ll see the output as shown in the following.
Whenever the user interacts with the app, Zone.js informs Angular, triggering change detection to update the view. However, this approach comes with a performance cost. Zone.js causes change detection to run after every asynchronous operation, even if no meaningful changes have been made to the app state. This can lead to unnecessary computations, particularly in large apps.
To check if Zone.js is present, open the browser’s console and type Zone (with an uppercase Z). You will see a description of the Zone class, as demonstrated in the following image.
What is zoneless change detection?
Zoneless change detection in Angular 18 introduces a more manual and precise way to manage change detection. This approach gives developers finer control over when change detection is triggered, improving performance and reducing memory overhead. By eliminating the reliance on Zone.js, Angular avoids unnecessary change detection cycles, resulting in faster and more efficient apps.
Advantages of not using Zone.js
Removing Zone.js as a dependency has several key benefits:
Better performance
Zone.js listens to DOM events and async tasks to trigger change detection, even when no real state change happens. This leads to unnecessary updates, slowing down the app.
Improved core web vitals
Zone.js adds extra load to the app, increasing both the file size and startup time.
Easier debugging
Zone.js makes debugging harder, with more complicated stack traces and issues when code runs outside Angular’s zone.
Better compatibility
Zone.js patches browser APIs, but it doesn’t cover all new APIs. Some, like async/await, can’t be patched well. Also, some libraries may not work smoothly with Zone.js. Removing it simplifies things and improves long-term compatibility.
How to enable zoneless change detection in an Angular 18 app?
Angular 18 introduced an experimental API, provideExperimentalZonelessChangeDetection, which allows us to configure an app to avoid using the state or state changes of Zone.js for scheduling change detection. This enables a more streamlined approach to change detection, giving developers greater control over when it is triggered.
Update the src\app\app.config.ts file to include this next function.
import { provideExperimentalZonelessChangeDetection } from '@angular/core';
// Other existing imports.
export const appConfig: ApplicationConfig = {
providers: [
provideExperimentalZonelessChangeDetection(),
// Existing providers.
],
};
We have removed the provideZoneChangeDetection function and included the new provideExperimentalZonelessChangeDetection function.
Zone.js is usually included through the polyfills option in the angular.json file for both build and test targets. To exclude it from the build, remove zone.js and zone.js/testing from both sections.
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
...
"polyfills": [
"zone.js" // Remove this line.
],
"tsConfig": "tsconfig.app.json",
"scripts": []
},
...
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js", // Remove this line.
"zone.js/testing" // Remove this line.
],
"tsConfig": "tsconfig.spec.json",
"scripts": []
...
}
}
Finally, remove the Zone.js package from the app by running the following command.
npm uninstall zone.js
Now run the app again. Open the browser console and check for the presence of Zone. You will see the following error.
This indicates that Zone.js is not available and our app is running in zoneless mode.
Check out the functionality of the app.
You can see that the click event handler is functioning as expected, as each click triggers a change detection when the button is pressed. Similarly, data retrieval via the async pipe is working correctly, since it triggers change detection whenever a new value is emitted by the observable.
However, the timer property, which relies on setInterval, is not being updated. In Zone.js-based change detection, events like setInterval or setTimeout are automatically detected. But with the zoneless change detection mechanism, changes within the setInterval function do not automatically trigger updates. To reflect these changes in the view, we must explicitly inform Angular that a change has occurred.
There are two ways to notify Angular for change detection in this case:
Using ChangeDetectorRef.markForCheck()
Using signals
Let’s understand the implementation of both these techniques with the help of code examples.
Using ChangeDetectorRef
Update the AppComponent class inside the src\app\app.component.ts file as follows:
export class AppComponent implements OnInit {
// Existing code.
private readonly changeDetectorRef = inject(ChangeDetectorRef);
ngOnInit() {
setInterval(() => {
this.timer++;
this.changeDetectorRef.markForCheck(); // Add this line to trigger change detection.
}, 1000);
}
// Existing code.
}
We have invoked the markForCheck method on the ChangeDetectorRef instance within the setInterval function. This instructs Angular to check the component for any changes.
Run the app again, and you’ll notice that the value of the timer property is now updating correctly. Refer to the following image.
Using signals
Update the AppComponent class inside the src\app\app.component.ts file.
export class AppComponent implements OnInit {
// Existing code.
timer = signal(0);
ngOnInit() {
setInterval(() => {
this.timer.update((t) => t + 1);
}, 1000);
}
// Existing code.
}
We have defined the timer property as a signal, initializing it to zero. Inside the setInterval function, we will use the signal’s update function to increment the value of the timer.
In the template, we will access the value of the signal using parentheses.
<h3>Async timer</h3>
<p>Timer value: {{ timer() }}</p>
Run the app again, and you’ll notice that the value of the timer property is now updating correctly.
Conclusion
In this article, we explored how Zone.js-based change detection works in Angular and its limitations. We also discussed the experimental zoneless change detection introduced in Angular 18 and how to activate it.
Zoneless Angular empowers developers to enhance change detection without the burden of automatic zone-based triggers. By effectively applying zoneless change detection in Angular, you can achieve notable performance gains and improve your app’s responsiveness.