Understanding ControlValueAccessor

Angular's Reactive Forms give developers a robust and adaptable system for managing inputs, validation, and user interactions. When building custom form components, there are situations where toggling the disabled state dynamically becomes necessary. The ControlValueAccessor interface is the key to handling this requirement.

What ControlValueAccessor Does

The ControlValueAccessor interface serves as the connection between Angular's forms layer and native DOM elements. It enables custom form controls to work seamlessly with Angular's forms infrastructure, making them behave identically to built-in form elements.

Building a Custom Control

To build a custom form control that respects the disabled property, implementing ControlValueAccessor is required. The following code sample demonstrates a basic structure:

import { Component, forwardRef, Input } from "@angular/core";
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";

@Component({
  selector: "app-custom-input",
  template: `<input [disabled]="isDisabled" (input)="onInput($event)" />`,
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => CustomInputComponent),
      multi: true,
    },
  ],
})
export class CustomInputComponent implements ControlValueAccessor {
  @Input() isDisabled = false;

  private onChange: (value: any) => void;
  private onTouched: () => void;

  writeValue(value: any): void {
    // Implement the write value logic here
  }

  registerOnChange(fn: any): void {
    this.onChange = fn;
  }

  registerOnTouched(fn: any): void {
    this.onTouched = fn;
  }

  setDisabledState(isDisabled: boolean): void {
    this.isDisabled = isDisabled;
  }

  onInput(event: Event): void {
    const input = event.target as HTMLInputElement;
    this.onChange(input.value);
  }
}
Enter fullscreen mode Exit fullscreen mode

Handling Disabled State Changes

The setDisabledState method plays a central role in this process. Whenever Angular needs to change the enabled or disabled status of a control, it invokes this method. Implementing it correctly means your custom control will mirror the disabled state that Angular forms dictate.

A Common Pitfall: Direct Disabled Attribute

Applying the disabled attribute directly to an input element inside a form control triggers a warning from Angular:

It looks like you're using the disabled attribute with a reactive form directive. If you set disabled to true when you define the form control, the disabled attribute will actually be set in the DOM for you. We recommend using this approach to avoid 'changed after checked' errors.

Correct Approaches for Disabling

  1. Rely on setDisabledState from ControlValueAccessor:

This approach guarantees that your custom component aligns with Angular's forms framework, letting Angular take charge of the disabled state management.

  1. Define the disabled state during form control initialization:

Specify the disabled status at the point of form control creation:

new FormControl({ value: "", disabled: true });
Enter fullscreen mode Exit fullscreen mode

An alternative is to change the state at runtime with control.disable().

Integrating the Custom Control into a Form

Once built, the custom control can be placed inside a reactive form exactly like any standard Angular form control:

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

@Component({
  selector: "app-root",
  template: `
    <form [formGroup]="form">
      <app-custom-input formControlName="customControl"></app-custom-input>
      <button type="button" (click)="toggleDisabled()">Toggle Disabled</button>
    </form>
  `,
})
export class AppComponent {
  form = new FormGroup({
    customControl: new FormControl(""),
  });

  toggleDisabled() {
    const control = this.form.get("customControl");
    control.disabled ? control.enable() : control.disable();
  }
}
Enter fullscreen mode Exit fullscreen mode

Controlling Disabled State via Form Controls

Reactive Forms make it straightforward to configure the disabled state right inside the form control definition:

@Component({
  selector: 'app-root',
  template: `
    <form [formGroup]="form">
      <app-custom-input formControlName="customControl"></app-custom-input>
    </form>
  `,
})
export class AppComponent {
  form = new FormGroup({
    customControl: new FormControl({ value: '', disabled: true }),
  });
}
Enter fullscreen mode Exit fullscreen mode

In the example above, customControl starts in a disabled state. The state can be switched dynamically whenever the application requires it.

Wrapping Up

Leveraging ControlValueAccessor for handling the disabled property in custom Angular form controls promotes uniform behavior across the entire application. Manually placing the disabled attribute on a DOM element should be avoided, as it leads to Angular warnings and potential runtime issues. Sticking to these recommended practices allows developers to craft reusable, adaptable, and accessible form components that fit smoothly into Angular's Reactive Forms, ultimately making the application more resilient and user-friendly.