Ron Newcomb
Ron Newcomb

Reputation: 3302

ngModel but using the reactive forms method of validation

I don't like either of Angular's form validation routes, but I'm investigating a way to combine the best of each, and am close to an answer I like.

I know that regardless whether I go the formBuilder or the ngModel route, there is an NgForm for the form, which has a property holding the root FormGroup which has a heterogeneous collection of FormControl objects. HTML Elements all have an adapter object implementing the ControlValueAccessor interface, and my own angular components like <date-range-picker> can implement the same interface and pretend to be just-another-element whose value is an arbitrarily complex object. Each FormControl wraps an element, talking with it via the ControlValueAccessor interface so it is agnostic as to what, exactly, it is actually talking to.

I know that placing either the ngModel or formControl directive on an element will create the FormControl instance for that element; the element doesn't get one automatically, even though a <form> tag gets an NgForm automatically.

I know that formBuilder will explicitly create hollow FormControls which lack the HTML element, but each has a name, and in the HTML the formControlName gives the HTML elements a name but no FormControl instance, and basically formControlName and formBuilder both talk to a service that matches the names and fills-in the hollow FormControl with its element.

Finally, FormControl is where the validators live, as well as the dirty/touched/etc. properties.

My issue with ngModel is the same as everyone's: validation sucks. A custom validation is little more than the condition of an if statement, but ngModel wants me to wrap that little condition in an entire directive and stick it in the HTML on the element. That's a lot of extra typing for an if statement -- you can't make a one-liner re-usable because it takes one line to use the wrapper. And cross-field validation sucks.

My issue with formBuilder is the assignment statements. For a model of 12 properties I'm writing 24 lines, 12 to put the values into the form and 12 to get them back out again, in a type-unsafe manner. That's a lot of extra typing that ngModel didn't require, and it kinda violates the DRY principle since I have to repeat the list and hierarchy of input fields in the HTML within the Typescript as well.

Lately I do this:

<input type=text name=foo [(ngModel)]="myModel.myProperty" />

with

@ViewChild(NgModel) mod: NgModel;
ngAfterViewInit() {
    this.mod.control.setValidators([Validators.required, Validators.minLength(3)]);
}

and

<span class=danger *ngIf="mod?.control?.errors?.required">....

This gives me the best of both worlds, concision and control.

But for <date-range-picker> I find I still have to use the ControlValueAccessor boilerplate, which means I then cannot use ngModel to shuttle values between its small 3-property object being returned and my official 12-property model source-of-truth. Three explicit assignment statements are needed. I want to avoid those, and avoid more angular-specific boilerplate.

It would be easy if the FormControls inside the picker's HTML could see the ngForm in the HTML using the picker, but it can't.

My question is: how does a FormControl register itself with an NgForm? FormBuilder doesn't take an NgForm as an input parameter, it 'just knows' which form to attach. Even if there's multiple forms in the same HTML template, it gets it right. If there's a service hiding behind it that goes and finds the NgForm, can I use that service from my picker to find an NgForm that is outside of its own template?

Upvotes: 3

Views: 3028

Answers (1)

Ron Newcomb
Ron Newcomb

Reputation: 3302

FormControls receive the NgForm instance from the ngModel / formControlName constructor, which is placed there by Angular DI. This "boilerplate" in a component's decorator:

{
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => DateRangePickerComponent),
    multi: true
}

...registers a custom component (which is implementing ControlValueAccessor) with the DI system. Specifically, NG_VALUE_ACCESSOR plays the same role as the PickerService does here:

export class MyComponent {
    constructor(pickerService: PickerService)

The multi: true part means the injected thing isn't just one service, like the PickerService is, but is actually a group of them. RadioControlValueAccessor, SelectControlValueAccessor, and CheckboxControlValueAccessor sit under this umbrella, and your own DateRangePicker could be among them if you use the "boilerplate." Angular selects the correct one for the job at hand when looking through an HTML template.

Wrapping a component in a lambda for forwardRef just solves a small order-of-initialization issue, nothing more.

Basically, implementing ControlValueAccessor makes a class what Angular expects, and the decorator specifies where in Angular to put it.

But if you really don't want to use it...

Use a template reference var on the form in the parent's HTML, and pass it to child component like it was any other value:

<form #theForm="ngForm" ...
    <date-range-picker [form]="theForm" ...

In the child component, accept the form like any other input, and also grab a reference to the ngModel used in the child's HTML (which you've already done for validator purposes):

@Input() form: NgForm;
@ViewChild(NgModel) mod: NgModel;

Add one to the other imperatively.

ngAfterViewInit() {
    this.mod.control.setValidators([Validators.required, c => c.value.duration != 0]);
    this.form.addControl(this.mod);
}

And you're basically done. There may be issues with *ngIf destroying and re-creating said control, or change detection not being as thorough as it normally would be, but solving these issues in this way means you're effectively re-inventing Angular.

This starts to become apparent when you have multiple ngModels in a child's template:

@ViewChildren(NgModel) ngModels: QueryList<NgModel>;

readonly validations = {
    'reasonField': [Validators.required, Validators.maxLength(500)],
    'durationField': [Validators.required, c => c.value.duration != 0],
};

ngAfterViewInit() {
    this.ngModels.forEach(ngModel => {
        ngModel.control.setValidators(this.validations[ngModel.name]);
        this.form.addControl(ngModel);
    });
}

..and now tying the validations to the fields starts to look a lot like formBuilder all over again. (Without the 24 assignment statements.)

Upvotes: 2

Related Questions