Search code examples
angulardata-binding

Setter is called twice with two-way binding in Angular


When using two-way binding in Angular, it seems that the setter of the child component is called twice.

Here is a playground that demonstrates the issue. If the "Toggle from component" button is clicked, the isShown setter of toggler.component.ts is called twice. I reproduced the interesting code below:

Parent component

@Component({changeDetection: ChangeDetectionStrategy.OnPush})
export class AppComponent implements DoCheck {
  public isShown = true;

  public onToggle() {
    this.isShown = !this.isShown;
  }
}

Child component

@Component({changeDetection: ChangeDetectionStrategy.OnPush})
export class TogglerComponent {

  public get isShown(): boolean {
    return this._isShown;
  }

  @Input()
  public set isShown(isShown: boolean) {
    console.log('Entering setter component');
    this._isShown = isShown;
    this.isShownChange.emit(isShown);
  }

  @Output()
  public isShownChange: EventEmitter<boolean> = new EventEmitter();

  private _isShown: boolean = true;

  public onToggle() {
    this.isShown = !this.isShown;
  }
}

How can I prevent the setter from being called twice? This behavior is problematic when the parent component initializes the bound variable asynchronously.


Solution

  • See this if you don't mind using rxjs and streams instead of getters/setters

    import { Component, ChangeDetectionStrategy, DoCheck } from '@angular/core';
    import { BehaviorSubject } from 'rxjs';
    import { shareReplay } from 'rxjs/operators';
    
    @Component({
      selector: 'my-app',
      templateUrl: './app.component.html',
      styleUrls: ['./app.component.css'],
      changeDetection: ChangeDetectionStrategy.OnPush,
    })
    export class AppComponent implements DoCheck {
      private readonly showSubject = new BehaviorSubject<boolean>(false);
    
      public readonly show$ = this.showSubject.asObservable().pipe(shareReplay());
    
      public toggle() {
        this.showSubject.next(!this.showSubject.value);
        console.log(
          `%c Outside: isShown changed to: ${this.showSubject.value}`,
          'color: blue;'
        );
      }
    
      public ngDoCheck() {
        console.log(
          `%c Outside: DoCheck: isShown: ${this.showSubject.value}`,
          'color: blue;'
        );
      }
    }
    
    
    <div>Outer isShown value: {{ show$ | async }}</div>
    <button (click)="toggle()">Toggle from outside</button>
    
    <br />
    <br />
    
    <toggler [show]="show$ | async" (showChange)="toggle()"></toggler>
    
    import {
      Component,
      ChangeDetectionStrategy,
      OnChanges,
      SimpleChanges,
      Input,
      Output,
      EventEmitter,
      DoCheck,
    } from '@angular/core';
    
    @Component({
      selector: 'toggler',
      template: `
        <div>
          <div>show: {{ show }}</div>
    
          <button (click)="toggle()">Toggle from component</button>
        </div>
      `,
      changeDetection: ChangeDetectionStrategy.OnPush,
      styles: [
        `
        :host {
          display: block;
          border: 1px dashed black;
          padding: 1em;
          color: green;
        }
      `,
      ],
    })
    export class TogglerComponent implements OnChanges, DoCheck {
      @Input() show?: boolean;
    
      @Output() showChange: EventEmitter<void> = new EventEmitter();
    
      public ngOnChanges(changes: SimpleChanges) {
        if (changes.show) {
          console.log(
            `%c OnChanges: isShown changed to: ${this.show}`,
            'color: green;'
          );
        }
      }
    
      public ngDoCheck() {
        console.log(`%c DoCheck: isShown: ${this.show}`, 'color: green;');
      }
    
      public toggle() {
        this.showChange.emit();
      }
    }