htmlangularangular-reactive-formsangular-formscontrolvalueaccessor

Angular FormGroup doesn’t get disabled when it contains an empty FormArray _and_ a custom form control


I have a simple FormGroup in an Angular component:

// app.component.ts, part 1
export class AppComponent implements OnInit {
  protected form = new FormGroup({
    ctl: new FormControl(),
    arr: new FormArray([]),
  });

The FormGroup can be disabled via a checkbox:

  // app.component.ts, part 1

  enabledCtl = new FormControl(true);

  ngOnInit() {
    this.enabledCtl.valueChanges.subscribe((enabled) => {
      if (enabled) {
        this.form.enable();
      } else {
        this.form.disable();
      }
    }

The FormArray, arr, is not represented in the HTML. The FormControl, ctl is bound to a custom component, custom-control.

<!-- app.component.html, part 1 -->
<input type="checkbox" id="enable" [formControl]="enabledCtl">
<label for="enable">Enabled</label>
<div [formGroup]="form">
  <custom-control formControlName="ctl"></custom-control>
</div>

The custom control implements ControlValueAccessor and is no more than a wrapper around another FormControl:

// custom-control.component.ts
@Component({
    selector: 'custom-control',
    imports: [ReactiveFormsModule],
    template: '',
    providers: [
      {
        provide: NG_VALUE_ACCESSOR,
        useExisting: forwardRef(() => CustomControlComponent),
        multi: true,
      },
    ]
})
export class CustomControlComponent implements ControlValueAccessor {

  protected ctl = new FormControl()

  // All of the rest is boilerplate.

  public writeValue(v: any): void {
    this.ctl.setValue(v)
  }

  public registerOnChange(fn: (...args: unknown[]) => unknown): void {
    this.ctl.valueChanges.subscribe(fn as any)
  }

  protected _onTouched = () => {}
  public registerOnTouched(fn: (...args: unknown[]) => unknown): void {
    const oldOnOnTouched = this._onTouched
    this._onTouched = () => {
      oldOnOnTouched()
      fn()
    }
  }

  public setDisabledState(isDisabled: boolean): void {
    if (isDisabled) {
      this.ctl.disable()
    } else {
      this.ctl.enable()
    }
  }

}

With this setup, when the user checks the checkbox bound to enabledCtl, the FormGroup form should get enabled. When they uncheck it, the form should get disabled.

However, when check the FormGroup's status, it never changes:

<!-- app.component.html, part 2 -->
Status: {{ form.disabled ? 'disabled' : 'enabled' }}
@if(form.enabled !== enabledCtl.value) {
  <strong> but should be {{ enabledCtl.value ? 'enabled' : 'disabled' }}</strong>
}

When the checkbox is unchecked, the FormGroup’s disabled flag doesn’t get changed and I get the following output in the HTML:

Status: enabled but should be disabled

The FormGroup’s status updates according to my expectations if I make any single one of these changes:

Only when I use a custom form control and have a FormArray as its sibling in the FormGroup does the FormGroup not update as I expect.

Why does the FormGroup’s disabled status not update with the above setup?

Stackblitz


Solution

  • When working with custom form control, do prefer Template Driven Forms since it is easier to code up.

    It is not impossible to do with reactive forms, but it's a lot of work (as you have seen). The disabled logic cannot be placed on setDisabledState, so I created a custom directive to toggle the input.

    @Directive({
      selector: '[disableDir]',
    })
    export class DisableDir {
      disabled = input(false, {
        alias: 'disableDir',
      });
      control = inject(NgControl);
    
      ngOnChanges() {
        if (this.disabled()) {
          this.control.control!.disable();
        } else {
          this.control.control!.enable();
        }
      }
    }
    

    We then use this directive and pass in the disabled property this will enable/disable the control.

    // A minimal custom control that only wraps a FormControl
    // and logs when it is enabled or disabled.
    @Component({
      selector: 'custom-control',
      imports: [ReactiveFormsModule, DisableDir],
      template: '<input [formControl]="control" [disableDir]="disabled"/>',
      providers: [
        {
          provide: NG_VALUE_ACCESSOR,
          useExisting: forwardRef(() => CustomControlComponent),
          multi: true,
        },
      ],
    })
    export class CustomControlComponent implements ControlValueAccessor {
      control = new FormControl('');
      onChange = (value: any) => {};
    
      onTouched = () => {};
    
      touched = false;
    
      disabled = false;
    
      ngOnInit() {
        this.control.valueChanges.subscribe((value: any) => {
          this.onChange(value);
        });
      }
    
      writeValue(value: any) {
        this.control.setValue(value);
      }
    
      registerOnChange(onChange: any) {
        this.onChange = onChange;
      }
    
      registerOnTouched(onTouched: any) {
        this.onTouched = onTouched;
      }
    
      markAsTouched() {
        if (!this.touched) {
          this.control.markAsTouched();
          this.onTouched();
          this.touched = true;
        }
      }
    
      setDisabledState(disabled: boolean) {
        this.disabled = disabled;
      }
    }
    

    Stackblitz Demo


    When trying the same with template driven forms (ngModel) the code more smaller and easily maintainable and understandable.

    // A minimal custom control that only wraps a FormControl
    // and logs when it is enabled or disabled.
    @Component({
      selector: 'custom-control',
      imports: [FormsModule],
      template:
        '<input [(ngModel)]="value" (ngModelChange)="onChangeInput()" [disabled]="disabled"/>',
      providers: [
        {
          provide: NG_VALUE_ACCESSOR,
          useExisting: forwardRef(() => CustomControlComponent),
          multi: true,
        },
      ],
    })
    export class CustomControlComponent implements ControlValueAccessor {
      value = '';
      onChange = (value: any) => {};
    
      onTouched = () => {};
    
      touched = false;
    
      disabled = false;
    
      onChangeInput() {
        this.onChange(this.value);
      }
    
      writeValue(value: any) {
        this.value = value;
      }
    
      registerOnChange(onChange: any) {
        this.onChange = onChange;
      }
    
      registerOnTouched(onTouched: any) {
        this.onTouched = onTouched;
      }
    
      markAsTouched() {
        if (!this.touched) {
          this.onTouched();
          this.touched = true;
        }
      }
    
      setDisabledState(disabled: boolean) {
        this.disabled = disabled;
      }
    }
    

    Stackblitz Demo