25
votes

My reactive form is three component levels deep. The parent component creates a new form without any fields and passes it down to child components.

At first the outer form is valid. Later on a child component adds new form elements with validators (that fail) making the outer form invalid.

I am getting an ExpressionChangedAfterItHasBeenCheckedError error in the console. I want to fix that error.

Somehow this only happens when I add the third level of nesting. The same approach seemed to work for two levels of nesting.

Plunker: https://plnkr.co/edit/GymI5CqSACFEvhhz55l1?p=preview

Parent component

@Component({
  selector: 'app-root',
  template: `
    myForm.valid: <b>{{myForm.valid}}</b>
    <form>
      <app-subform [myForm]="myForm"></app-subform>
    </form>
  `
})
export class AppComponent implements OnInit {
  ...

  ngOnInit() {
    this.myForm = this.formBuilder.group({});
  }
}

Sub component

@Component({
  selector: 'app-subform',
  template: `
    <app-address-form *ngFor="let addressData of addressesData;"
      [addressesForm]="addressesForm">
    </app-address-form>
  `
})
export class SubformComponent implements OnInit {
  ...

  addressesData = [...];

  constructor() { }

  ngOnInit() {
    this.addressesForm = new FormArray([]);
    this.myForm.addControl('addresses', this.addressesForm);
  }

Child component

@Component({
  selector: 'app-address-form',
  template: `
    <input [formControl]="addressForm.controls.addressLine1">
    <input [formControl]="addressForm.controls.city">
  `
})
export class AddressFormComponent implements OnInit {
  ...

  ngOnInit() {
    this.addressForm = this.formBuilder.group({
      addressLine1: [
        this.addressData.addressLine1,
        [ Validators.required ]
      ],
      city: [
        this.addressData.city
      ]
    });

    this.addressesForm.push(this.addressForm);
  }
}

enter image description here

4
I visited that page 5 times in the past 24 hours ;). Still not sure how to fix it though. - Erik Nijland
there's no error in the plunker you referenced - Max Koretskyi
Weird. I am getting the error in both Chrome and Firefox with all extensions disabled. Both in Plunker as well as on localhost. Added an screenshot for reference. - Erik Nijland
I am getting the error using the plunk. I removed the validation ([ Validators.required ]) in the address form component, and the error went away. Not sure if that information is helpful. - R. Richards

4 Answers

39
votes

To understand the problem you need to read Everything you need to know about the ExpressionChangedAfterItHasBeenCheckedError error article.

For your particular case the problem is that you're creating a form in the AppComponent and use a {{myForm.valid}} interpolation in the DOM. It means that Angular will run create and run updateRenderer function for the AppComponent that updates DOM. Then you use the ngOnInit lifecycle hook of subcomponent to add subgroup with control to this form:

export class AddressFormComponent implements OnInit {
  @Input() addressesForm;
  @Input() addressData;

  ngOnInit() {
    this.addressForm = this.formBuilder.group({
      addressLine1: [
        this.addressData.addressLine1,
        [ Validators.required ]   <-----------
      ]

    this.addressesForm.push(this.addressForm); <--------

The control becomes invalid because you don't supply initial value and you specify a required validator. Hence the entire form becomes invalid and the expression {{myForm.valid}} evaluates to false. But when Angular ran change detection for the AppComponent it evaluated to true. And that's what the error says.

One possible fix could be to mark the form as invalid in the start since you're planning to add required validator, but it seems Angular doesn't provide such method. Your best choice is probably to add controls asynchronously. In fact, this is what Angular does itself in the sources:

const resolvedPromise = Promise.resolve(null);

export class NgForm extends ControlContainer implements Form {
  ...

  addControl(dir: NgModel): void {
    // adds controls asynchronously using Promise
    resolvedPromise.then(() => {
      const container = this._findContainer(dir.path);
      dir._control = <FormControl>container.registerControl(dir.name, dir.control);
      setUpControl(dir.control, dir);
      dir.control.updateValueAndValidity({emitEvent: false});
    });
  }

So for you case it will be:

const resolvedPromise = Promise.resolve(null);

@Component({
   ...
export class AddressFormComponent implements OnInit {
  @Input() addressesForm;
  @Input() addressData;

  addressForm;

  ngOnInit() {
    this.addressForm = this.formBuilder.group({
      addressLine1: [
        this.addressData.addressLine1,
        [ Validators.required ]
      ],
      city: [
        this.addressData.city
      ]
    });

    resolvedPromise.then(() => {
       this.addressesForm.push(this.addressForm); <-------
    })
  }
}

Or use some variable in the AppComponent to hold form state and use it in the template:

{{formIsValid}}

export class AppComponent implements OnInit {
  myForm: FormGroup;
  formIsValid = false;

  constructor(private formBuilder: FormBuilder) {}

  ngOnInit() {
    this.myForm = this.formBuilder.group({});
    this.myForm.statusChanges((status)=>{
       formIsValid = status;
    })
  }
}
1
votes
import {ChangeDetectorRef} from '@angular/core';
....

export class SomeComponent {

  form: FormGroup;

  constructor(private fb: FormBuilder,
              private ref: ChangeDetectorRef) {
    this.form = this.fb.group({
      myArray: this.fb.array([])
    });
  }

  get myArray(): FormArray {
    return this.form.controls.myArray as FormArray;
  }

  addGroup(): void {
    const newGroup = this.fb.group({
      prop1: [''],
      prop2: ['']
    });

    this.myArray.push(newGroup);
    this.ref.detectChanges();
  }
}
1
votes

I had the same scenario and same issue in Angular 9 and above solution works fine. I tweaked it a little bit: synchroinously adding the control without validators and adding the required validators asynchronously... Because I needed the controls immediately otherwise I got an error cannot find formControl ....

This is my solution based on the accepted answer above:

const resolvedPromise = Promise.resolve(null);
@Component({
  selector: 'password-input',
  templateUrl: './password-input.component.html',
  styleUrls: ['./password-input.component.css']
})
export class PasswordInputComponent implements OnInit {
  @Input() parentFormGroup : FormGroup;
  @Input() controlName : string = 'password';
  @Input() placeholder : string = 'Password';
  @Input() label : string = null;

  ngOnInit(): void {
    this.parentFormGroup.addControl(this.controlName, new FormControl(null));
   
    resolvedPromise.then( () => {
      this.parentFormGroup.get(this.controlName).setValidators(Validators.required)
      this.parentFormGroup.get(this.controlName).updateValueAndValidity();
    });
  }
0
votes

Following the solution of Max Koretskyi, we can use async/await pattern on ngOnInit.

Here is an example:

@Component({
  selector: 'my-sub-component',
  templateUrl: 'my-sub-component.component.html',
})
export class MySubComponent implements OnInit {
  @Input()
  form: FormGroup;

  async ngOnInit() {
    // Avoid ExpressionChangedAfterItHasBeenCheckedError
    await Promise.resolve(); 

    this.form.removeControl('config');
    
    this.form.addControl('config', new FormControl("", [Validator.required]));       
  }
}