4

I'm using a SET method to set my bound value to 'DEFAULT' if we ever attempt to set it to an empty string. The bound text input behaves properly in every scenario, EXCEPT if the text field already says 'DEFAULT' and I "select all + delete" that value. The bound value properly gets set back to 'DEFAULT' immediately, but the input field remains empty.

The get/set methods

the template

Here's a stackblitz showing the issue https://stackblitz.com/edit/angular-a9j8tv?file=src%2Fapp%2Fapp.component.html

If you have 'DEFAULT' and you select all and delete, you can see that the input stays empty, but the span which is bound to the same value properly shows the newly re-set 'DEFAULT' value.

Why does this happen? Is there a work around?

Leviathan3
  • 69
  • 8

2 Answers2

4

Inherent error

The ngModel directive uses @Input and @Output to obtain the value and emit the change event. So binding a function like setter or getter to a directive like [ngModel] with default change detection strategy would trigger the function for each change detection.

To replicate this error, plug in a console.log in the getter, type some value and keep pressing backspace in the <input> and let go. You could see the console.log messages keeps getting printed.

Error replication: Stackblitz


Why isn't 'DEFAULT' inserted on Ctrl+a+Backspace

As said before, the ngModel directive uses @Input and correspondingly the ngOnChanges() hook to fetch any changes to the value from <input> tag. One important thing to note is ngOnChanges() won't be triggered if the value hasn't changed(1).

We could try to split the two-way binding [(ngModel)] to [ngModel] input and (ngModelChange) output.

export class AppComponent {
  public testString: string;

  onChange(value: string) {
    this.testString = value === '' ? 'DEFAULT' : value;
  }
}
<input type="text" [ngModel]="testString" (ngModelChange)="onChange($event)" />

Still error prone: Stackblitz

Here the Ctrl+a+Backspace any string other than 'DEFAULT' would insert 'DEFAULT' as expected. But Ctrl+a+Backspace 'DEFAULT' would result in empty <input> box due the ngOnChanges() issue discussed above.


Solution

One way to achieve expected behavior is to not depend on the ngModel directive's ngOnChanges() hook to set the value, but to do it ourselves manually. For that we could use an Angular template reference variable in the <input> and send it to the component for further manipulation.

export class AppComponent {
  public testString: string;

  public onChange(value: string, inputElem: HTMLInputElement) {
    this.testString = value === '' ? 'DEFAULT' : value;
    inputElem.value = this.testString;
  }
}
<input
  type="text"
  #myInput
  ngModel
  (ngModelChange)="onChange($event, myInput)"
/>

Working example: Stackblitz

Here the #myInput is the template reference variable. If this feels hacky, you'd need to know using ngModel to manipulate <input> is in itself called template driven forms. And it's quite legal to manipulate the template ref directly.

With all that said, IMO in cases where such input manipulation is needed, I'd recommend you to use the Angular Reactive Forms instead. It provides a more granular control over the input.


(1) The ngOnChanges() won't be triggered if the previousValue and currentValue of the respective @Input variable's SimpleChange object hasn't changed.

ruth
  • 29,535
  • 4
  • 30
  • 57
  • Thank you for the clear explanation of what is going on with the ngModel directive. That was definitely the piece of the puzzle that I was missing. I think the important piece of the onChange method is that it specifically sets the input element's value rather than relying on ngModel binding. Keeping the test logic in the setter (or otherwise outsourcing that logic) still seems to show the desired result https://stackblitz.com/edit/angular-xd9trm?file=src/app/app.component.ts – Leviathan3 Nov 19 '21 at 21:28
  • @Leviathan3: Ofcourse it's similar to calling functions to adjust and get the value. But note that you'd still run into the same issue I mentioned at the start of the answer. The interpolation `{{ testString }}` would trigger the getter multiple times which might lead to performance issues later: [Stackblitz](https://stackblitz.com/edit/angular-njkjye?file=src/app/app.component.html). Just a rule of thumb, try to avoid using functions, setters and getters for property binding (eg. `[ngModel]="testString") and interpolation `{{ testString }}`. – ruth Nov 20 '21 at 10:05
2

The issue comes from ngModel. That since the '' value manipulating to the DEFAULT, which is the latest value that has been fired by ngModel, ngModel does not realize the value change to fire new value changed event.

How to workaround;

I have assigned the changed value directly to the variable in the setter before comparison. Then set a minimal timeout to ngModel could update its existing value. Then when I compare the new value and change it back to DEFAULT in the case of '', ngModel realizes the value change and fires the new value changed event. This updates both representations.

Please find updated stackblitz

Mehmet YILMAZ
  • 111
  • 1
  • 6
  • That doesn't seem to explain why it works in every other scenario. Typing anything other than 'DEFAULT' into the field, then doing the select all + delete, gets the correct behavior even though the input is still focused – Leviathan3 Nov 18 '21 at 23:33
  • @Leviathan3 you are right, that I have not realized this behavior. – Mehmet YILMAZ Nov 18 '21 at 23:58
  • As I understand; since the previous emitted value is the same as the actual value on the ngModel update method (which we are manipulating to DEFAULT when we delete all in once), ngModel does not fire the new value update event. Because there is no value update from ngModel, span text keeps the latest fired value as DEFAULT, and the input stays empty. – Mehmet YILMAZ Nov 19 '21 at 00:54
  • @Leviathan3 Could you please check the update. This may fit you. – Mehmet YILMAZ Nov 19 '21 at 11:50
  • Thanks, that makes sense. Using setTimeout of 0 to force trigger a change (as in your staackblitz) does seem to solve the issue too. – Leviathan3 Nov 19 '21 at 21:22