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:
<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?
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;
}
}
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;
}
}