I have an Angular v18.2 application that uses zoneless change detection:
export const appConfig: ApplicationConfig = {
providers: [provideExperimentalZonelessChangeDetection(), provideRouter(routes)]
};
The zone.js
polyfill has also been removed from angular.json
.
I have a counter component that uses an object as a signal:
counter.component.ts:
interface CounterModel {
value: number
}
@Component({
selector: 'app-counter',
standalone: true,
imports: [],
templateUrl: './counter.component.html',
styleUrl: './counter.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CounterComponent {
counterModel: WritableSignal<CounterModel> = signal({
value: 0
})
increment() {
this.counterModel().value++
}
decrement() {
this.counterModel().value--
}
}
counter.component.html:
<p>{{ counterModel().value }}</p>
<button (click)="decrement()">Decrement</button>
<button (click)="increment()">Increment</button>
Note the increment
and decrement
methods simply update the value
property of the object. They do NOT change the object reference for the signal. Given this, I would expect that change detection would not run, and the view would not update when clicking the Decrement/Increment buttons.
However, contrary to my expectations, the view DOES update. Why is that? Clearly there is something about zoneless change detection and signals that I am not understanding.
Also, is this behavior something that I can safely rely on? Or would it be better to update the CounterModel.value
property to use a nested signal, or else create a new instance of CounterModel
in the increment
and decrement
methods? Would appreciate any guidance regarding best practices here.
The button click you make is marking the component as dirty hence change detection is running, since objects are just memory references.
When you update the inner property the memory reference of the signal remains the same and when the view is refreshed, the latest value is displayed in the UI, below is an example of setInterval
calling the same function but the view does not update.
Angular relies on notifications from core APIs in order to determine when to run change detection and on which views. These notifications include:
- ChangeDetectorRef.markForCheck (called automatically by AsyncPipe)
- ComponentRef.setInput
- Updating a signal that's read in a template
- Bound host or template listeners callbacks <----- (this!!!!)
- Attaching a view that was marked dirty by one of the above
import {
Component,
provideExperimentalZonelessChangeDetection,
ChangeDetectionStrategy,
WritableSignal,
signal,
} from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
interface CounterModel {
value: number;
}
@Component({
selector: 'app-root',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p>{{ counterModel().value }}</p>
<button (click)="decrement()">Decrement</button>
<button (click)="increment()">Increment</button>
`,
})
export class App {
counterModel: WritableSignal<CounterModel> = signal({
value: 0,
});
ngOnInit() {
setInterval(() => {
this.increment();
}, 1000);
}
increment() {
this.counterModel().value++;
}
decrement() {
this.counterModel().value--;
}
}
bootstrapApplication(App, {
providers: [provideExperimentalZonelessChangeDetection()],
});