Angular Forms: Why is ngModelChange late when updating ngModel value

Post Editor

Understanding some of the inner workings of Angular Forms by solving an interesting problem.

3 min read
1 comment
post

Angular Forms: Why is ngModelChange late when updating ngModel value

Understanding some of the inner workings of Angular Forms by solving an interesting problem.

post
post
3 min read
1 comment
1 comment

The @angular/forms package is rich in functionalities and although is widely used, it still has some unsolved mysteries. The aim of this article is to clarify why the problem in question occurs and how it can be solved. This involves strong familiarity with Angular Forms, so it would be preferable to read A thorough exploration of Angular Forms first, but not mandatory, as I will cover the necessary concepts once again in the following sections.

This article has been inspired by this Stack Overflow question.

The problem
Link to this section

Let's say we want to create a directive which will perform some changes on what the user types into an input, so that the bound FormControl will have the altered value.

Our directive may look like this:

<>Copy
@Directive({ selector: '[myDirective]' }) export class Mydirective { constructor(private control: NgControl) { } processInput(value: any) { return value.toUpperCase(); } @HostListener('ngModelChange', ['$event']) ngModelChange(value: any) { this.control.valueAccessor.writeValue(this.processInput(value)); } }

and could be used like this:

<>Copy
<hello name="{{ name }}"></hello> <input class="form-control" id="label" [(ngModel)]='modelValue' required myDirective> Model: {{ modelValue }}

In this snippet we're using the well known banana in a box syntax, which is the same as: [ngModel]='modelValue' (ngModelChange)='modelValue = $event'.

Here's the corresponding StackBlitz:

As soon as we start typing into the input, the problem becomes evident.

Understanding the problem
Link to this section

Note: ControlValueAccessor does not refer to a certain entity (such as an interface), but to the concept behind it.

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:

<>Copy
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:

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

<>Copy
/* ... */ @Output('ngModelChange') update = new EventEmitter(); /* ... */ viewToModelUpdate(newValue: any): void { this.viewModel = newValue; this.update.emit(newValue); } /* ... */

What this means is that the model value is the value from the input (in lowercase). 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. Here is how DefaultValueAccessor.writeValue() is defined:

<>Copy
writeValue(value: any): void { const normalizedValue = value == null ? '' : value; this._renderer.setProperty(this._elementRef.nativeElement, 'value', normalizedValue); }

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

And this is the snippet depicted in the image above:

<>Copy
function setUpModelChangePipeline(control: FormControl, dir: NgControl): void { control.registerOnChange((newValue: any, emitModelEvent: boolean) => { // control -> view dir.valueAccessor!.writeValue(newValue); // control -> ngModel if (emitModelEvent) dir.viewToModelUpdate(newValue); }); }

The solution
Link to this section

A way to solve the problem is to add this snippet to the directive:

<>Copy
ngOnInit () { const initialOnChange = (this.ngControl.valueAccessor as any).onChange; (this.ngControl.valueAccessor as any).onChange = (value) => initialOnChange(this.processInput(value)); }

With this approach, we're modifying the 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:

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

Conclusion
Link to this section

A ControlValueAccessor is responsible for keeping in sync the 2 main layers, VIEW and MODEL. By understanding some of the inner workings of Angular Forms, we were able to see why the problem occurred and how to solve it.

Thanks for reading!

Comments (1)

authorGokul-Official
3 October 2021

Hi, this helped me to save the day !!!

Love this implementation and way of explanation

Thanks for this Manh

authorAndrei0872
4 October 2021

Hi @Gokul-Official, I'm glad it was helpful!

Share

About the author

author_image

A curious software developer with a passion for solving problems and learning new things.

author_image

About the author

Andrei Gatej

A curious software developer with a passion for solving problems and learning new things.

About the author

author_image

A curious software developer with a passion for solving problems and learning new things.

Looking for a JS job?
Job logo
Full Stack Java/Angular Developer

Black Knight

Worldwide
Remote
$70k - $90k
Job logo
Angular Developer

Ziras Technologies

United States
Remote
$58k - $145k
Job logo
Front-end Developer (Angular)

Adaptiq

Ukraine
Remote
Job logo
React or Angular Front-end developer

Soshace LLC

Worldwide
Remote
$50k - $96k
More jobs
NxAngularCli
NxAngularCli
NxAngularCli

Featured articles