Angular ngModelChange is late when updating NgModel

TLDR

StackBlitz.

my-directive.directive.ts

/* ... */

ngOnInit () {
  const initialOnChange = (this.ngControl.valueAccessor as any).onChange;

  (this.ngControl.valueAccessor as any).onChange = (value) => initialOnChange(this.processInput(value));
}

/* ... */

@HostListener('ngModelChange', ['$event'])
ngModelChange(value: any) {
  this.ngControl.valueAccessor.writeValue(this.processInput(value));
}

Detailed answer

Let's see why it didn't work initially.

Angular has default value accessors for certain elements, such as for input type='text', input type='checkbox' etc...

A ControlValueAccessor is the middleman between the VIEW layer and the MODEL layer. When a user types into an input, the VIEW notifies the ControlValueAccessor, which has the job to inform the MODEL.

For instance, when the input event occurs, the onChange method of the ControlValueAccessor will be called. Here's how onChange looks like for every ControlValueAccessor:

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);
  });
}

The magic happens in updateControl:

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;
}

dir.viewToModelUpdate(control._pendingValue); is what invokes the ngModelChange event in the custom directive. What this means is that the model value is the value from the input(in lowercase). And because ControlValueAccessor.writeValue only writes the value to the VIEW, there will be a delay between the VIEW's value and the MODEL's value.

It's worth mentioning that FormControl.setValue(val) will write val to both layers, VIEW and MODEL, but if we were to used this, there would be an infinite loop, since setValue() internally calls viewToModelUpdate(because the MODEL has to be updated), and viewToModelUpdate calls setValue().

Let's have a look at a possible solution:

ngOnInit () {
  const initialOnChange = (this.ngControl.valueAccessor as any).onChange;

  (this.ngControl.valueAccessor as any).onChange = (value) => initialOnChange(this.processInput(value));
}

With this approach, you're modifying your data at the VIEW layer, before it is sent to the ControlValueAccessor.

And we can be sure that onChange exists on every built-in ControlValueAccessor.

search results

If you are going to create a custom one, just make sure it has an onChange property. TypeScript can help you with that.

If you'd like to read more about internals of @angular/forms, I'd recommend having a look at A thorough exploration of Angular Forms.


you can get it using

  @HostListener('input', ['$event'])
  ngModelChange(event: any) {
    const item = event.target
    const value = item.value;
    const pos = item.selectionStart;
    this.control.control.setValue(this.processInput(value), { emit: false });
    item.selectionStart = item.selectionEnd = pos
  }

See that we use @HostListener input, to get the item, not only the value. This allow us position the cursor in his position after change the value

NOTE: To make a simple uppercase it's better use css text-transform:uppercase and, when we want to get the value use toUpperCase()

NOTE2: about mask see this SO

Tags:

Angular