1

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:

  • I replace the FormControl with another FormArray and remove the binding in the HTML
  • I remove the FormArray from the FormGroup
  • I bind the FormControl to a regular HTML input element, say <input type="text" formControlName="ctl">

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

2 Answers 2

1

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

Sign up to request clarification or add additional context in comments.

3 Comments

Thank you for your answer. I’m afraid to say that most of your changes are actually unnecessary. In particular, the DisableDir is not needed. It suffices to not emit an event on the setDisabledState call (you avoided the emission by not calling control.disable()).
@bleistift2 But if we do not have that the inner input will not disable, also, we should not use [disabled] attribute when working with reactive forms, so I suggested the directive
I’m sorry for the confusion. I have updated my comment, apparently concurrently with your answer. If you check stackblitz.com/edit/… you will see that the input does disable correctly, and that the FormGroup behaves correctly, as well. The only relevant change to my original Stackblitz demo is the {emitEvent: false} in CustomControlComponent::setDisabledState. @naren-murali
0

In your custom component use {onlySelf:true,emitEvent:false}

  public setDisabledState(isDisabled: boolean): void {
    this.disabled = isDisabled;
    if (isDisabled) {
      console.log('Disabling custom control');
      this.ctl.disable({onlySelf:true,emitEvent:false});
    } else {
      console.log('Enabling custom control');
      this.ctl.enable({onlySelf:true,emitEvent:false});
    }
  }

BTW: Better than use a custom formControl to create a component that "group" a serie of inputs use a custom control with viewProviders:[{ provide: ControlContainer, useExisting: NgForm }] a custom formControl should be created only to make a "complex" control, like a serie of check-box, a dataPicker, a circular slider, and so on. I know it's a lost cause but I want to fight

See how your "customControlComponent" (really it's not a customControlComponent") becomes like

@Component({
  selector: 'custom-control',
  imports: [ReactiveFormsModule,JsonPipe],
  template: `
     <input type="text" [controlName]="ctl()">
  `,
  viewProviders:[{ provide: ControlContainer, useExisting:  FormGroupDirective }]
})
export class CustomControlComponent  {
  ctl=input.required<string>({alias:'controlName'});
}

And use

<custom-control controlName="ctl"></custom-control>

(we can not use formControlName)

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.