Chris Rockwell
Chris Rockwell

Reputation: 1852

Updating disabled status on a Reactive form not working when using .patchValue

I have a reactive form that uses a toggle button to show different fields. If "Email" is selected then a text field is shown that requires email validation. If "Sms" is shown, a different field is shown which requires different validation (In real life the field has additional validation for a number, but I don't think it's necessary in the example).

On first page load, when you click "Sms", I get the following error:

ERROR Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'false'. Current value: 'true'.

This is happening on the submit button when checking if the form is valid. It is correct that the form is no longer valid; however, I don't think I'm handling this correctly for two reasons:

  1. The error is shown (I understand this only happens in dev environments)
  2. If you click "Sms" and back to "Email", the form is technically valid again and the "Save Settings" should no longer be disabled.

I have a stackblitz with the minimum amount of code needed to reproduce: https://angular-ivy-xgfrve.stackblitz.io/. Just click on "Sms" and you'll get the error. The "Save settings" button is appropriately disabled; however, if you click "Email" again it remains disabled, even though the form is now valid.

The code included in the Stackblitz is here:

app.component.ts


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

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: [ './app.component.css' ]
})
export class AppComponent implements OnInit {
  name = 'Angular ' + VERSION.major;
  DELIVERY_MODES = {
    sms: 'sms',
    email: 'email'
  };
  deliveryForm: FormGroup;

  constructor(private formBuilder: FormBuilder) {}

  ngOnInit(): void {
    const dummyData = new PlatformEnrollmentDelivery();
    dummyData.hour = 10;
    dummyData.minute = 30;
    dummyData.modeTarget = "[email protected]"
    this.deliveryForm = this.formBuilder.group({
      deliveryConfiguration: this.initDeliveryModeFormGroup(dummyData),
      setDefaults: this.formBuilder.group({
        setDefault: false
      })
    });
  }

  initDeliveryModeFormGroup(delivery: PlatformEnrollmentDelivery): any {
    const defaultMode = delivery.mode ? delivery.mode.toLowerCase() : this.DELIVERY_MODES.email;
    return this.formBuilder.group({
      mode: [defaultMode],
      email: this.formBuilder.group(this.initEmailDeliveryMode(delivery)),
      sms: this.formBuilder.group(this.initSmsDeliveryMode(delivery)),
      deliveryTime: this.formBuilder.group(this.initDeliveryTime(delivery))
    });
  }

  initEmailDeliveryMode(delivery: PlatformEnrollmentDelivery): object {
    const emailControlValue = delivery.mode.toLowerCase() === this.DELIVERY_MODES.email ? delivery.modeTarget : '';
    return {
      emailControl: [emailControlValue]
    };
  }

  initSmsDeliveryMode(delivery: PlatformEnrollmentDelivery): object {
    const smsControlValue = delivery.mode.toLowerCase() === this.DELIVERY_MODES.sms ? delivery.modeTarget : '';
    return {
      smsControl: [smsControlValue]
    };
  }

  initDeliveryTime(delivery: PlatformEnrollmentDelivery): object {
    return {
      timeControl: ["10:00"]
    };
  }

  setDeliveryMethodType(type: string): void {
    this.deliveryForm.controls.deliveryConfiguration.get('mode').patchValue(type);
    switch (type) {
      case this.DELIVERY_MODES.email:
        this.deliveryForm.controls.deliveryConfiguration.get('email').get('emailControl')
          .setValidators([Validators.required, Validators.email]);
        this.deliveryForm.controls.deliveryConfiguration.get('sms').get('smsControl').clearValidators();
        break;
      case this.DELIVERY_MODES.sms:
        this.deliveryForm.controls.deliveryConfiguration.get('sms').get('smsControl')
          .setValidators([Validators.required]);
        this.deliveryForm.controls.deliveryConfiguration.get('email').get('emailControl').clearValidators();
        break;
    }
  }
}

export class PlatformEnrollmentDelivery {
  id: number;
  mode: string;
  hour: number;
  updated: Date;
  minute: number
  modeTarget: string;

  constructor(
  ) {
      this.mode = "email";
  }
}

app.component.html

<form [formGroup]="deliveryForm">
  <h4>Receive questions via:</h4>
  <mat-button-toggle-group
    [value]="deliveryForm.controls.deliveryConfiguration.get('mode').value"
    formGroupName="deliveryConfiguration"
    #group="matButtonToggleGroup"
    id="modeReactive"
    name="modeReactive"
    (change)="setDeliveryMethodType(group.value)"
    aria-label="Mode"
  >
    <mat-button-toggle [value]="DELIVERY_MODES.email">
      {{ DELIVERY_MODES.email | titlecase }}
    </mat-button-toggle>
    <mat-button-toggle [value]="DELIVERY_MODES.sms">
      {{ DELIVERY_MODES.sms | titlecase }}
    </mat-button-toggle>
  </mat-button-toggle-group>
  <div formGroupName="deliveryConfiguration">
    <div
      *ngIf="
        deliveryForm.controls.deliveryConfiguration.get('mode').value ===
        DELIVERY_MODES.email
      "
    >
      <mat-form-field formGroupName="email" appearance="fill">
        <mat-label>Email address</mat-label>
        <input matInput type="email" formControlName="emailControl" />
      </mat-form-field>
    </div>
    <div
      *ngIf="
        deliveryForm.controls.deliveryConfiguration.get('mode').value ===
        DELIVERY_MODES.sms
      "
    >
      <div formGroupName="sms" class="mat-form-field-wrapper">
        <div class="tel-input-wrapper">
          <mat-label>Email address</mat-label>
          <input
            matInput
            type="textfield"
            name="phone"
            formControlName="smsControl"
          />
          <div class="mat-form-field-underline ng-tns-c182-0 ng-star-inserted">
            <span class="mat-form-field-ripple ng-tns-c182-0"></span>
          </div>
        </div>
      </div>
    </div>
    <div formGroupName="deliveryTime">
      <mat-form-field appearance="fill">
        <mat-label>Select a Delivery Time</mat-label>
        <input
          matInput
          type="textfield"
          name="phone"
          formControlName="timeControl"
        />
      </mat-form-field>
    </div>
  </div>
  
  <button
    [disabled]="!deliveryForm.valid"
    mat-raised-button
    button
    color="primary"
    type="submit"
  >
    
    Save Settings
  </button>
</form>

Upvotes: 1

Views: 2111

Answers (1)

Owen Kelvin
Owen Kelvin

Reputation: 15083

Problem Explanation

This error occurs if you update your UI after change detection has occurred.

Lets try to follow what happens in your code

  • user clicks on button to change email
  • You have set formGroupName="deliveryConfiguration" so this triggers change detection, we are still at valid form
  • Now you call (change)="setDeliveryMethodType(group.value)" which changes the form to invalid and the UI is to be updated again...

Solution

A simple solution is to simply use the ChangeDetectorRef. We inject it can call the detectChanges() function If we log the deliveryForm object we get

{
  "setDefaults": {
    "setDefault": ""
  },
  "deliveryConfiguration": {
    "mode": "email",
    "email": {
      "emailControl": "[email protected]"
    },
    "sms": {
      "smsControl": ""
    },
    "deliveryTime": {
      "timeControl": "10:00"
    }
  }
}

We note that from the above object, we can simply set the button control like below

  <ng-container formGroupName="deliveryConfiguration">
    <mat-button-toggle-group
      [value]="deliveryForm.get('deliveryConfiguration.mode').value"
      formControlName="mode"
      #group="matButtonToggleGroup"
      id="modeReactive"
      name="modeReactive"
      aria-label="Mode"
    >
      <mat-button-toggle [value]="DELIVERY_MODES.email">
        {{ DELIVERY_MODES.email | titlecase }}
      </mat-button-toggle>
      <mat-button-toggle [value]="DELIVERY_MODES.sms">
        {{ DELIVERY_MODES.sms | titlecase }}
      </mat-button-toggle>
    </mat-button-toggle-group>
  </ng-container>

Note a few changes

  • No change event
  • formControlName="mode"
  • Control wrapped in a <ng-container formGroupName="deliveryConfiguration"> ... </ng-container>

Lets now use the .valueChanges property to track the mode change

    this.deliveryForm.get('deliveryConfiguration.mode').valueChanges.subscribe({
      next: (res) => this.setDeliveryMethodType(res),
    });

The final step we can clean up the setDeliveryMethodType method

    setDeliveryMethodType(type: string): void {
    const emailControl = this.deliveryForm.get(
      'deliveryConfiguration.email.emailControl'
    );
    const smsControl = this.deliveryForm.get(
      'deliveryConfiguration.sms.smsControl'
    );
    switch (type) {
      case this.DELIVERY_MODES.email:
        emailControl.setValidators([Validators.required, Validators.email]);
        smsControl.clearValidators();
        break;
      case this.DELIVERY_MODES.sms:
        smsControl.setValidators([Validators.required]);
        emailControl.clearValidators();

        break;
    }
    emailControl.updateValueAndValidity();
    smsControl.updateValueAndValidity();
    this.cdr.detectChanges();
  }

See Demo Here

Update

this.cdr.detectChanges(); does the trick however we can actually remove it altogether... The reason that removing this line works is that when we call updateValueAndValidity(); Angular triggers another change detection life cycle hence the error will not be thrown and everything should still work as expected. In the below demo I have removed this.cdr.detectChanges();

Demo working without ChangeDetectorRef

Upvotes: 1

Related Questions