Theo Stefou
Theo Stefou

Reputation: 645

Rendering a dynamic form in angular using ControlValueAccessor causes ExpressionChangedAfterItHasBeenCheckedError

I would like to render an angular form dynamically, based on a json schema object that describes the fields. I would like to render multiple types of fields, but for this example I will only use text fields. The schema object could look like this:

fields: any[] = [
    {
      key: 'field1',
      label: 'Label 1',
      type: 'text',
      required: true
    },
    {
      key: 'field2',
      label: 'Label 2',
      type: 'text',
      required: true
    }
]

Given the schema above, 2 text fields should be rendered

main.component.html
<form [formGroup]="form">
    <span *ngFor="let field of fields">
        <app-dynamic-form-field [field]="field" [formControlName]="field.key"></app-dynamic-form-field>
        <br>
    </span>
</form>
main.component.ts
ngOnInit(): void {
    const formGroup: any = {};
    for(let field of this.fields) {
      formGroup[field.key] = [null];
    }

    this.form = this.fb.group(formGroup);

    this.form.valueChanges.subscribe(val => {
      console.log(this.form.value);
    })
  }
dynamic-form-field.component.html
<app-text-field *ngIf="field.type == 'text'" [field]="field" [formControl]="formControl"></app-text-field>

<app-number-field *ngIf="field.type == 'number'" [field]="field" [formControl]="formControl"></app-number-field>

<app-select-field *ngIf="field.type == 'select'" [field]="field" [formControl]="formControl"></app-select-field>
dynamic-form-field.component.ts
import { Component, Input, OnInit } from '@angular/core';
import { AbstractControl, ControlValueAccessor, FormBuilder, FormControl, FormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, Validator, Validators } from '@angular/forms';

@Component({
  selector: 'app-dynamic-form-field',
  templateUrl: './dynamic-form-field.component.html',
  styleUrls: ['./dynamic-form-field.component.scss'],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      multi: true,
      useExisting: DynamicFormFieldComponent
    },
    {
      provide: NG_VALIDATORS,
      useExisting: DynamicFormFieldComponent,
      multi: true
    }
  ]
})
export class DynamicFormFieldComponent implements OnInit, ControlValueAccessor, Validator {

  @Input({required: true})
  field: any;

  formControl!: FormControl;

  onChange = (value:any) => {};
  onTouched = () => {};
  onValidationChange = () => {};

  constructor(private fb: FormBuilder) {}

  ngOnInit(): void {
    this.formControl = new FormControl();

    this.formControl.valueChanges.subscribe(val => {
      this.onTouched();
      this.onChange(val);
      this.onValidationChange();
    })
  }


  // Control Value Accessor
  writeValue(obj: any): void {
    this.formControl.setValue(obj, {emitEvent: false});
  }

  registerOnChange(fn: any): void {
    this.onChange = fn;
  }

  registerOnTouched(fn: any): void {
    this.onTouched = fn;
  }

  setDisabledState(isDisabled: boolean): void {
    if(isDisabled) {
      this.formControl.disable({emitEvent: false});
    }
    else {
      this.formControl.enable({emitEvent: false});
    }
  }

  // Validator
  validate(control: AbstractControl<any, any>): ValidationErrors | null {
    if(this.formControl.valid) return null;
    return {error: true};
  }
  registerOnValidatorChange(fn: () => void): void {
    this.onValidationChange = fn;
  }

}
text-field.component.html
<mat-form-field>
    <input matInput
        type="text"
        [placeholder]="field.label"
        [formControl]="formControl">
</mat-form-field>
text-field.component.ts
import { Component, Input, OnInit } from '@angular/core';
import { AbstractControl, ControlValueAccessor, FormBuilder, FormControl, FormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, Validator, Validators } from '@angular/forms';

@Component({
  selector: 'app-text-field',
  templateUrl: './text-field.component.html',
  styleUrls: ['./text-field.component.scss'],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      multi: true,
      useExisting: TextFieldComponent
    },
    {
      provide: NG_VALIDATORS,
      useExisting: TextFieldComponent,
      multi: true
    }
  ]
})
export class TextFieldComponent implements OnInit, ControlValueAccessor, Validator {

  @Input({required: true})
  field: any;

  formControl!: FormControl;

  onChange = (value:any) => {};
  onTouched = () => {};
  onValidationChange = () => {};

  ngOnInit(): void {
    this.formControl = new FormControl<string>('');

    if(this.field.required) {
      this.formControl.addValidators(Validators.required);
    }

    this.formControl.valueChanges.subscribe(val => {
      this.onTouched();
      this.onChange(val);
      this.onValidationChange();
    })
  }

  writeValue(obj: any): void {
    this.formControl.setValue(obj, {emitEvent: false});
  }

  registerOnChange(fn: any): void {
    this.onChange = fn;
  }

  registerOnTouched(fn: any): void {
    this.onTouched = fn;
  }

  setDisabledState(isDisabled: boolean): void {
    if(isDisabled) {
      this.formControl.disable();
    }
    else {
      this.formControl.enable();
    }
  }

  // Validator
  validate(control: AbstractControl<any, any>): ValidationErrors | null {
    if(this.formControl.valid) return null;
    return {error: true};
  }
  registerOnValidatorChange(fn: () => void): void {
    this.onValidationChange = fn;
  }
}

So, basically, I would like to delegate the dynamic nature of the field to a "factory" component called DynamicFormFieldComponent. This works fine, but when the form is rendered, I get the following error:

main.ts:5 ERROR Error: NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value for 'ng-untouched': 'true'. Current value: 'false'. Expression location: MainComponent component. Find more at https://angular.io/errors/NG0100
    at throwErrorIfNoChangesMode (core.mjs:11622:11)
    at bindingUpdated (core.mjs:14851:17)
    at checkStylingProperty (core.mjs:18266:32)
    at Module.ɵɵclassProp (core.mjs:18174:5)
    at NgControlStatus_HostBindings (forms.mjs:65:104)
    at processHostBindingOpCodes (core.mjs:11853:21)
    at refreshView (core.mjs:13544:9)
    at detectChangesInView (core.mjs:13663:9)
    at detectChangesInEmbeddedViews (core.mjs:13606:13)
    at refreshView (core.mjs:13522:9)

When I skip the inbetween DynamicFormComponent and instead render the fields in the main component, this error is not thrown. I have found the following solutions, which both seem too hacky to me and I would like to avoid them:

  1. Add a call to detect changes in main component inside ngAfterViewChecked
  2. use changeDetection: ChangeDetectionStrategy.OnPush inside main components @Component tag

EDIT: Also, the whole thing works fine, I just can't get rid of the error. Adding enableProdMode() in main.ts makes the error go away, but this seems like a hack too.

Upvotes: 1

Views: 102

Answers (0)

Related Questions