1
votes

I'm building a custom component that wraps several form fields and implementing ControlValueAccessor to interact with the reactive form in its parent component. I'm also setting up validation for the custom form component using NG_VALIDATORS, essentially following this guide:

https://blog.thoughtram.io/angular/2016/07/27/custom-form-controls-in-angular-2.html

The child form is then toggled on/off by getting wrapped in a <div> tag with *ngIf.

The problem I'm facing is the validator function is getting "re-hooked up" once for each time the component gets shown and then hidden. Each previous reference to the validator function is remembered, so if the control were to be hidden and then shown again 5 times, the validator gets fired 5 times for each change detection cycle instead of just once.

Any help or guidance is appreciated.

To demonstrate the problem I've added a console.log to the validation function.

plunkr: https://plnkr.co/edit/nIj6WR?p=preview

App Component

import { BrowserModule } from '@angular/platform-browser';
import { NgModule, Component, OnInit } from '@angular/core';
import { ReactiveFormsModule, FormGroup, FormBuilder } from '@angular/forms';
import { HttpModule } from '@angular/http';

import { AppComponent } from './app.component';
import { ChildFormComponent } from './child-form.component';

@Component({
  selector: 'app-root',
  template: `
<h2>
  Form Value
</h2>
<div>
  <pre>
  {{form.value | json}}
</pre>
</div>
<form [formGroup]="form">
  <label for="value1">Value 1</label>
  <input id="value1" type="text" formControlName="value1" />
  <h3>Show child form?</h3>
  <div>
    <label for="showChildFormYes">Yes</label>
    <input id="showChildFormYes" type="radio" formControlName="showChildForm" value="yes" />
    <label for="showChildFormNo">No</label>
    <input id="showChildFormNo" type="radio" formControlName="showChildForm" value="no" />
  </div>
  <div *ngIf="showChildForm">
    <app-child-form formControlName="childValue"></app-child-form>
  </div>
</form>
`
})
export class AppComponent implements OnInit {
  form: FormGroup;
  showChildForm: boolean;

  constructor(private formBuilder: FormBuilder) { }

  ngOnInit() {
    console.log('initializing app form');

    this.form = this.formBuilder.group({
      value1: '',
      childValue: '',
      showChildForm: 'no'
    });

    this.form.get('showChildForm').valueChanges.subscribe(value => {
      this.showChildForm = value === 'yes';
    });
  }
}

@NgModule({
  declarations: [
    AppComponent,
    ChildFormComponent
  ],
  imports: [
    BrowserModule,
    ReactiveFormsModule,
    HttpModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Child Form Component

import {
  Component,
  OnInit,
  OnDestroy,
  forwardRef
} from '@angular/core';

import {
  FormGroup,
  FormControl,
  FormBuilder,
  ControlValueAccessor,
  NG_VALUE_ACCESSOR,
  NG_VALIDATORS,
  ValidatorFn
} from '@angular/forms';

const CHILD_FORM_VALUE_ACCESSOR = {
  provide: NG_VALUE_ACCESSOR,
  useExisting: forwardRef(() => ChildFormComponent),
  multi: true
};

const CHILD_FORM_VALIDATORS = {
  provide: NG_VALIDATORS,
  useExisting: forwardRef(() => ChildFormComponent),
  multi: true
};

function validatorFnFactory() {
  return (control: FormControl): ValidatorFn => {
    console.log('VALIDATING', control.value);
    return null;
  }
}

@Component({
  selector: 'app-child-form',
  template: `
<p>
  child-form works!
</p>
<form [formGroup]="form">
  <input type="text" formControlName="value1" />
</form>
  `,
  providers: [
    CHILD_FORM_VALUE_ACCESSOR,
    CHILD_FORM_VALIDATORS
  ]
})
export class ChildFormComponent implements OnInit, OnDestroy, ControlValueAccessor {
  form: FormGroup;
  validator: ValidatorFn;

  constructor(private formBuilder: FormBuilder) {
    this.validator = validatorFnFactory();
  }

  ngOnInit() {
    console.log('initializing child form');

    this.form = this.formBuilder.group({
      value1: ''
    });

    this.form.valueChanges.subscribe(value => {
      this.value = value;
    })
  }

  ngOnDestroy() {
    console.log('destroying child form');
  }

  validate(control: FormControl) {
    this.validator(control);
  }

  _value: any = '';

  get value(): any { return this._value; };

  set value(v: any) {
    if (v !== this._value) {
      this._value = v;
      this.onChange(v);
    }
  }

  writeValue(value: any) {
    this._value = value;
    this.onChange(value);
  }

  onChange = (_) => { };
  onTouched = () => { };
  registerOnChange(fn: (_: any) => void): void { this.onChange = fn; }
  registerOnTouched(fn: () => void): void { this.onTouched = fn; }
}
1

1 Answers

0
votes

I haven't looked at your code in detail but *ngIf doesn't just show/hide a piece of markup. It evaluates and inserts it in the DOM, or it removes it from the DOM.

So the behavior you describe makes sense: every time your *ngIf evaluates to true, <app-child-form> is re-evaluated and the associated validation gets triggered again.

A quick workaround would be to actually show/hide the markup using CSS or the DOM hidden property, i.e. <div [hidden]="!showChildForm">.

Side note: you might run into another problem with your *ngIf. Just because you remove <app-child-form> from the form template doesn't remove it from the form model (this.form = this.formBuilder.group(...)). You could end up in a situation where the model says the child form is required but you have no markup (and no value) for this form in the template.