ControlValueAccessor ngModel not being updated

Issue

This is simple custom form control

@Component({
  selector: 'app-custom-control',
  template: `
    {{ value }}
    <input [ngModel]="value" (ngModelChange)="onChange($event)">
  `,
  providers: [{
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => CustomControlComponent),
    multi: true,
  }]
})
export class CustomControlComponent implements ControlValueAccessor {

  private value: any;

  private onChange: (val) => void;
  private onTouch: () => void;

  writeValue(value: any) {
    this.value = value;
  }

  registerOnChange(fn: any): void {
    this.onChange = fn;
  }

  registerOnTouched(fn: any): void {
    this.onTouch = fn;
  }
}

Used like below:

@Component({
  selector: 'my-app',
  template: `
    <app-custom-control
      [ngModel]="model"
      (ngModelChange)="onChange($event)">
    </app-custom-control>
    <input [ngModel]="model" (ngModelChange)="onChange($event)">
  `,
  styleUrls: [ './app.component.css' ]
})
export class AppComponent  {
  model = 'hello';

  onChange(value) {
    this.model = value;
  }
}

What I fail to understand is why ngModel of the control is only being updated from changing value of the outer input, but not in the case of using inner input?
Live example here: https://stackblitz.com/edit/angular-7apjhg

Edit:

Actual problem can be seen with simpler example (without inner input):

@Component({
  selector: 'app-custom-control',
  template: `
    {{ value }}
    <button (click)="onChange('new value')">set new value</button>
  `,
  providers: [{
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => CustomControlComponent),
    multi: true,
  }]
})
export class CustomControlComponent implements ControlValueAccessor {

  value: any;

  onChange: (val) => void;
  onTouched: () => void;

  writeValue(value: any) {
    this.value = value;
  }

  registerOnChange(fn: any): void {
    this.onChange = fn;
  }

  registerOnTouched(fn: any): void {
    this.onTouched = fn;
  }
}

After clicking the button inside the custom control, property value on parent is updated, but ngModel is not. Updated example: https://stackblitz.com/edit/angular-tss2f3

Solution

In order for this to work, you’ll have to use the banana in the box syntax for the input that resides inside custom-control.component.ts

custom-control.component.ts

<input [(ngModel)]="value" (ngModelChange)="onChange($event)">

Working Example.


That happens because when you are typing into the outer input, the CustomControlComponent‘s ControlValueAccessor.writeValue() will be executed, which in turn will update the inner input.

Let’s break it into smaller steps.

1) type in the outer input

2) change detection is triggered

3) the ngOnChanges from NgModel directive(that is bound to custom-control) will eventually reached, which will cause the FormControl instance to be updated in the next tick

@Directive({
  selector: '[ngModel]:not([formControlName]):not([formControl])',
  providers: [formControlBinding],
  exportAs: 'ngModel'
})
export class NgModel extends NgControl implements OnChanges,
    OnDestroy {
 /* ... */
 ngOnChanges(changes: SimpleChanges) {
    this._checkForErrors();
    if (!this._registered) this._setUpControl();
    if ('isDisabled' in changes) {
        this._updateDisabled(changes);
    }

    if (isPropertyUpdated(changes, this.viewModel)) {
        this._updateValue(this.model);
        this.viewModel = this.model;
    }

  /* ... */

 private _updateValue(value: any): void {
    resolvedPromise.then(
        () => { this.control.setValue(value, { emitViewToModelChange: false }); 
    });
  }
 }
}

4) FormControl.setValue() will invoke the registered change function callback, which will in turn invoke ControlValueAccessor.writeValue

control.registerOnChange((newValue: any, emitModelEvent: boolean) => {
    // control -> view
    dir.valueAccessor !.writeValue(newValue);

    // control -> ngModel
    if (emitModelEvent) dir.viewToModelUpdate(newValue);
  });

Where dir.valueAccessor !.writeValue(newValue) will be the CustomControlComponent.writeValue function.

writeValue(value: any) {
    this.value = value;
}

This is why you’re inner input is being updated by the outer one.


Now, why doesn’t it work the other way around?

When you’re typing into the inner input, it will only invoke its onChange function, which would look like this:

function setUpViewChangePipeline(control: FormControl, dir: NgControl): void {
  dir.valueAccessor !.registerOnChange((newValue: any) => {
    control._pendingValue = newValue;
    control._pendingChange = true;
    control._pendingDirty = true;

    if (control.updateOn === 'change') updateControl(control, dir);
  });
}

Which will again the updateControl function.

function updateControl(control: FormControl, dir: NgControl): void {
  if (control._pendingDirty) control.markAsDirty();
  control.setValue(control._pendingValue, {emitModelToViewChange: false});
  dir.viewToModelUpdate(control._pendingValue);
  control._pendingChange = false;
}

Looking inside updateControl, you’ll see that it has the { emitModelToViewChange: false } flag. Peeking into FormControl.setValue(), we’ll see that the flag prevents the inner input from being updated.

setValue(value: any, options: {
    onlySelf?: boolean,
    emitEvent?: boolean,
    emitModelToViewChange?: boolean,
    emitViewToModelChange?: boolean
  } = {}): void {
    (this as{value: any}).value = this._pendingValue = value;

    // Here!
    if (this._onChange.length && options.emitModelToViewChange !== false) {
      this._onChange.forEach(
          (changeFn) => changeFn(this.value, options.emitViewToModelChange !== false));
    }
    this.updateValueAndValidity(options);
  }

In fact, only the inner input is not updated, but the FormControl instance bound to that input is updated. This can be seen by doing this:

custom-control.component.html

{{ value }}

<input #i="ngModel" [ngModel]="value" (ngModelChange)="onChange($event)">

{{ i.control.value | json }} <!-- Always Updated -->

Answered By – Andrei G─âtej

Answer Checked By – David Marino (AngularFixing Volunteer)

Leave a Reply

Your email address will not be published.