angularangular-reactive-formsangular-ngmodelform-controlangular-template-form

Behavior of ngModel vs formControlName for radio elements


While working with validating same radio input elements with two different approaches viz. template-driven-form and model-driven-form, I am stuck with the scenario where for template-driven-form, using ngModel, I get single instance of control for 3 radio elements but for model-driven-form, using formControlName, I get 3 separate instances.

<!-- template-driven-form.component.html -->
<div class="form-group gender">
  <label for="gender">Select Gender:</label>
  <div class="radio" *ngFor="let gender of genders">
    <input type="radio" name="gender" [value]="gender" ngModel appFormControlValidation validationMsgId="gender" required />
    <label>{{ gender }}</label>
  </div>
</div>

<!-- model-driven-form.component.html -->
<div class="form-group gender">
  <label for="gender">Select Gender:</label>
  <div class="radio" *ngFor="let gender of genders">
    <input type="radio" name="gender" [value]="gender" formControlName="gender" appFormControlValidation validationMsgId="gender" required />
    <label>{{ gender }}</label>
  </div>
</div>
// model-driven-form.component.ts
genders: string[] = ['Male', 'Female', 'Other'];
this.modelForm = new FormGroup({
  gender: new FormControl(null, [Validators.required])
});

// template-driven-form.component.ts
genders: string[] = ['Male', 'Female', 'Other'];

// form-control-directive
(this.control as NgControl).statusChanges.subscribe(
  // returns single instance for 3 radio elements -> template form
  // returns 3 instance for 3 radio elements -> model form
);

As from snippet above, I am using same HTML structure for both forms yet number instances vary. Problem here is when the validation happens, for template-driven-form, I get error message only once (which is expected scenario) but for model-driven-form, I get error messages displayed 3 times!

VALIDATION_SCENARIO

My question is:

  1. Why is number of instance generated for same element type different for ngModel and formControlName?
  2. What changes are required so that formControlName also returns single instance?

Working Stackbliz version


Solution

  • Answered by @AndreiGatej - https://github.com/indepth-dev/community/discussions/53

    Description:
    After a quick investigation, it is actually expected to get three instances. Concretely, the statusChanges from this.statusChangeSubscription = this.control?.statusChanges?.subscribe() is subscribed 3 times. If you have N radio buttons, then you'll end up with N instances of that directive. This implies that although you're having N instances, they will all inject the same NgControl instance(which can be NgModel, FormControlName and FormControlDirective). And that NgControl instance is, in the case of reactive forms, this one:

    gender: new FormControl(null, [Validators.required]),
    

    The reason this doesn't apparently happen when using NgModel is that when using Template-Driven forms, the form control directives are created on the fly. This is the flow that sets up an NgModel directive:

    1. this.formDirective.addControl(this).
    2. The NgModel's NgControl is added in the Form Directives Tree at the end of this tick. The reason for that can be found here.
    3. The interesting part is that although each NgModel directive inherently creates a unique FormControl instance, in the directives tree there will be only one FormControl instance which will be shared by all the NgModel which share the same name. Here is the piece of logic which indicates that a single FormControl instance will be shared:
    if (this.controls[name]) return this.controls[name];
       this.controls[name] = control;
       control.setParent(this);
       control._registerOnCollectionChange(this._onCollectionChange);
       return control;
    

    registerControl is called after the resolvedPromise promise has been resolved. It's also there where dir.control is assigned to whatever registerControl returns.

    So, the directive's ngOnInit is called before that promise(which is essentially Promise.resolve()) resolves. At the end of the current tick, all the NgModel directives will share the same FormControl instance. This is why it works as expected.