angularangular2-changedetectionangular-signalsangular-zoneless

Angular Zoneless: Changing property value for an object-based signal unexpectedly updates the view


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.


Solution

  • 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.


    Requirements for Zoneless compatibility

    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()],
    });
    

    Stackblitz Demo