Why does the form validation break when the form is passed down more than one level?

64 views Asked by At

I need to pass down a form through multiple nested components within my Angular application. In "lower" levels / deeper nested components I need to add some new controls with validators. For some reason it is working, when I only pass down the form one level, however at two levels it breaks. I don't understand why the error that I am getting is related to how deep the form is nested. I have tried different scenarios and it seems to come down to passing the form and its validation more than one level.

I am guessing this has to do something with the angular lifecycle and the order in which the components are instantiated but I cannot seem to figure out how to fix it. I should note that I am still a beginner at angular so please bare with me I am trying my hardest :')

Disclaimer: This is a follow-up question to a prior question of mine, where I was helped to find the solve my initial problem of mixing template and reactive forms, however unfortunately the solution to my initial question (see: scenario 1) did not work for an additional level of nesting which I need in my application (see: scenario 2).

Scenario 1: I am passing down the form through App->Parent. In the parent I am adding some new Controls with some validators. This does not give me any error. The motivation for not adding all controls with their validators at the top level is that in the actual application I have a complex form with a lot of different sub-components all of which are responsible for adding the controls and the validators that they need. I prefer to keep the logic where it is needed (in the sub-components) due to Single Responsibility Principle.

import { Component, OnInit } from '@angular/core';
import { FormGroup } from '@angular/forms';

@Component({
  selector: 'app-root',
  template: `<app-parent [form]="form"></app-parent>
    <button [disabled]="!valid">Button</button>`,
  styleUrls: [],
})
export class AppComponent implements OnInit {
  form: FormGroup = new FormGroup({});

  ngOnInit(): void {
    console.log('AppComponent ngOnInit');
  }

  get valid() {
    console.log('AppComponent validating', this.form.valid ? "(valid)" : "(invalid)");
    return this.form.valid;
  }
}

import { Component, Input, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-parent',
  template: `<div [formGroup]="form">
    <input formControlName="child1"/>
    <input formControlName="child2"/>
  </div>`,
  styles: [],
})
export class ParentComponent implements OnInit {
  @Input()
  form: FormGroup = new FormGroup({});

  ngOnInit(): void {
    console.log('ParentComponent ngOnInit');

    this.form.addControl('child1', new FormControl('', Validators.required));
    this.form.addControl(
      'child2',
      new FormControl('value', Validators.required)
    );
  }
}

In this scenario I get the following console output (no error):

app.component.ts:14 AppComponent ngOnInit
parent.component.ts:17 ParentComponent ngOnInit
app.component.ts:18 AppComponent validating (invalid)

Scenario 2: However when I pass down the form one more level such as App->Container->Parent for some reason it breaks. Please know that I am aware that in this minimal code this layer of nesting is absolutely unnecessary however it my actual application it is required.

import { Component, OnInit } from '@angular/core';
import { FormGroup } from '@angular/forms';

@Component({
  selector: 'app-root',
  template: `<app-container [form]="form"></app-container>
    <button [disabled]="!valid">Button</button>`,
  styleUrls: [],
})
export class AppComponent implements OnInit {
  form: FormGroup = new FormGroup({});

  ngOnInit(): void {
    console.log('AppComponent ngOnInit');
  }

  get valid() {
    console.log('AppComponent validating', this.form.valid ? "(valid)" : "(invalid)");
    return this.form.valid;
  }
}

import { Component, Input, OnInit } from '@angular/core';
import { FormGroup } from '@angular/forms';

@Component({
  selector: 'app-container',
  template: `<app-parent [form]="form"></app-parent>`,
  styleUrls: [],
})
export class ContainerComponent implements OnInit {
  @Input()
  form!: FormGroup;

  ngOnInit(): void {
    console.log('ContainerComponent ngOnInit');
  }
}

import { Component, Input, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-parent',
  template: `<div [formGroup]="form">
    <input formControlName="child1"/>
    <input formControlName="child2"/>
  </div>`,
  styles: [],
})
export class ParentComponent implements OnInit {
  @Input()
  form: FormGroup = new FormGroup({});

  ngOnInit(): void {
    console.log('ParentComponent ngOnInit');

    this.form.addControl('child1', new FormControl('', Validators.required));
    this.form.addControl(
      'child2',
      new FormControl('value', Validators.required)
    );
  }
}

Now I am getting this output:

app.component.ts:14 AppComponent ngOnInit
container.component.ts:14 ContainerComponent ngOnInit
app.component.ts:18 AppComponent validating (valid)
parent.component.ts:17 ParentComponent ngOnInit
app.component.ts:18 AppComponent validating (invalid)
main.ts:6 ERROR Error: NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value for 'disabled': 'false'. Current value: 'true'. Expression location: AppComponent component. Find more at https://angular.io/errors/NG0100
2 app.component.ts:18 AppComponent validating (invalid)

Scenario 3: Ideally I would like to use custom components implementing the ControlValueAccessor adn Validator interfaces to display my form controls. The valdiators would therefore no longer be added in the parent component but instead be responsibility of the child. This however also seems to depend on how far the form is passed down. If it goes App->Child it works, however on App->Parent->Child (or App->Container->Parent of course) it breaks.

App->Child (this works)

import { Component, OnInit } from '@angular/core';
import {FormControl, FormGroup} from '@angular/forms';

@Component({
  selector: 'app-root',
  template: `<div [formGroup]="form">
        <app-child formControlName="child1" key="child1"/>
        <app-child formControlName="child2" key="child2"/>
    </div>
    <button [disabled]="!valid">Button</button>`,
  styleUrls: [],
})
export class AppComponent implements OnInit {
  form: FormGroup = new FormGroup({child1: new FormControl(''), child2: new FormControl('value')});

  ngOnInit(): void {
    console.log('AppComponent ngOnInit');
  }

  get valid() {
    console.log('AppComponent validate', this.form.valid ? "(valid)" : "(invalid)");
    return this.form.valid;
  }
}

import { Component, Input, OnInit } from '@angular/core';
import {
  AbstractControl,
  ControlValueAccessor,
  NG_VALIDATORS,
  NG_VALUE_ACCESSOR,
  ValidationErrors,
  Validator,
} from '@angular/forms';

@Component({
  selector: 'app-child',
  template: `<div>
    <input [(ngModel)]="value" (ngModelChange)="onChange(value)" />
  </div>`,
  styles: [],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      multi: true,
      useExisting: ChildComponent,
    },
    {
      provide: NG_VALIDATORS,
      multi: true,
      useExisting: ChildComponent,
    },
  ],
})
export class ChildComponent implements ControlValueAccessor, Validator, OnInit {
  @Input() key = '';

  value = '';

  ngOnInit(): void {
    console.log('ChildComponent', this.key, 'ngOnInit');
  }
  
  onChange = (_: string) => {};
  onTouched = () => {};

  writeValue(obj: string): void {
    this.value = obj;
  }
  registerOnChange(fn: any): void {
    this.onChange = fn;
  }
  registerOnTouched(fn: any): void {
    this.onTouched = fn;
  }

  validate(_: AbstractControl): ValidationErrors | null {
    if (!this.value) {
      console.log('ChildComponent', this.key, 'validate (invalid)');
      return { required: true };
    }

    console.log('ChildComponent', this.key, 'validate (valid)');
    return null;
  }
}

Gives the console output:

app.component.ts:17 AppComponent ngOnInit
child.component.ts:36 ChildComponent child1 ngOnInit
child.component.ts:54 ChildComponent child1 validate (invalid)
child.component.ts:36 ChildComponent child2 ngOnInit
child.component.ts:58 ChildComponent child2 validate (valid)
app.component.ts:21 AppComponent validate (invalid)

App->Parent->Child (this does not work)

import { Component, OnInit } from '@angular/core';
import { FormGroup } from '@angular/forms';

@Component({
  selector: 'app-root',
  template: `<app-parent [form]="form"></app-parent>
    <button [disabled]="!valid">Button</button>`,
  styleUrls: [],
})
export class AppComponent implements OnInit {
  form: FormGroup = new FormGroup({child1: new FormControl(''), child2: new FormControl('value')});

  ngOnInit(): void {
    console.log('AppComponent ngOnInit');
  }

  get valid() {
    console.log('AppComponent validating', this.form.valid ? "(valid)" : "(invalid)");
    return this.form.valid;
  }
}

import { Component, Input, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-parent',
  template: `<div [formGroup]="form">
    <app-child formControlName="child1" key="child1"></app-child>
    <app-child formControlName="child2" key="child2"></app-child>
  </div>`,
  styles: [],
})
export class ParentComponent implements OnInit {
  @Input()
  form: FormGroup = new FormGroup({});

  ngOnInit(): void {
    console.log('ParentComponent ngOnInit');
  }
}

@Component({
  selector: 'app-child',
  template: `<div>
    <input [(ngModel)]="value" (ngModelChange)="onChange(value)" />
  </div>`,
  styles: [],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      multi: true,
      useExisting: ChildComponent,
    },
    {
      provide: NG_VALIDATORS,
      multi: true,
      useExisting: ChildComponent,
    },
  ],
})
export class ChildComponent implements ControlValueAccessor, Validator, OnInit {
  @Input() key = '';

  value = '';

  ngOnInit(): void {
    console.log('ChildComponent', this.key, 'ngOnInit');
  }
  
  onChange = (_: string) => {};
  onTouched = () => {};

  writeValue(obj: string): void {
    this.value = obj;
  }
  registerOnChange(fn: any): void {
    this.onChange = fn;
  }
  registerOnTouched(fn: any): void {
    this.onTouched = fn;
  }

  validate(_: AbstractControl): ValidationErrors | null {
    if (!this.value) {
      console.log('ChildComponent', this.key, 'validate (invalid)');
      return { required: true };
    }

    console.log('ChildComponent', this.key, 'validate (valid)');
    return null;
  }
}

This gives me the output:

app.component.ts:14 AppComponent ngOnInit
parent.component.ts:17 ParentComponent ngOnInit
app.component.ts:18 AppComponent validate (valid)
child.component.ts:36 ChildComponent child2 ngOnInit
child.component.ts:54 ChildComponent child2 validate (invalid)
child.component.ts:36 ChildComponent child2 ngOnInit
child.component.ts:58 ChildComponent child2 validate (valid)
app.component.ts:18 AppComponent validate (invalid)
main.ts:6 ERROR Error: NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value for 'disabled': 'false'. Current value: 'true'. Expression location: AppComponent component. Find more at https://angular.io/errors/NG0100
app.component.ts:18 AppComponent validate (invalid)

0

There are 0 answers