Stop re-implementing ControlValueAccessor

cyrillbrito

Cyrill Brito

Posted on June 5, 2023

Stop re-implementing ControlValueAccessor

Drake meme

Almost every developer has come to the point where he wants to create his own form components, like a custom text input, a pretty file picker, or just a wrapper component of a library.

To make sure that this custom component works on Template and Reactive forms you will need to implement the ControlValueAccessor, but some of the times this can be receptive and unnecessary if all you want is to pass the value without changing it.

In this article, I will showcase a way to avoid re-implementing the ControlValueAccessor but still be able to use the Forms API.

ControlValueAccessor

Defines an interface that acts as a bridge between the Angular forms API and a native element in the DOM. - Angular Docs

I will not dive into the ways this interface works, but here is a basic implementation example:



@Component({
  standalone: true,
  imports: [FormsModule],
  selector: 'app-custom-input',
  template: `
    <input
      [ngModel]="value"
      (ngModelChange)="onChange($event)"
      [disabled]="isDisabled"
      (blur)="onTouched()"
    />
  `,
  providers: [{
    provide: NG_VALUE_ACCESSOR,
    multi: true,
    useExisting: CustomInputComponent,
  }],
})
export class CustomInputComponent implements ControlValueAccessor {

  value: string;
  isDisabled: boolean;
  onChange: (value: string) => void;
  onTouched: () => void;

  writeValue(value: any) {
    this.value = value || '';
  }

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

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

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


Enter fullscreen mode Exit fullscreen mode

Implementing this is not hard, but it can be lengthy and repetitive. Most of the time we don't even want to change the way the value is bound to the input, so using the angular provided binds would be enough.
Let's look at a way we can stop re-implementing the same logic once and once again.

NgModel under the hood

Let's have a peek at how the ngModel directive syncs its value with the component.

The directive injects the NG_VALUE_ACCESSOR to be able to interact with the underlying component, this is the reason why we always need to provide it in our form components.
For the browser native inputs, angular includes some directives that provide NG_VALUE_ACCESSOR, one of them being the DefaultValueAccessor.

The ngModel creates a FormControl that it uses to keep the state of the control, like value, disabled, touched...

Diagram of the components and how the ngModel directive interacts with them

Based on this we can see that our custom component looks like a bridge between the app and the DefaultValueAccessor, we can also see that there are 2 FormControl being created in this example, one on each ngModel.

Taking into account that our custom component is just a bridge and that the 1st ngModel already has a FormControl, we can grab this control and just pass it to the input without modifying it in any way.

Accessing the directive control

The ngModel, formControl, and formControlName are the 3 directives that allow a component to interact with the forms API. From inside a component, we can have access to these directives by injecting the token NgControl.

NgControl class diagram

So doing this we will have access to the directive that in turn has the FormControl that we can use.

Here is an example of how this would look like, but for now we still need a dummy value accessor for Angular to see the component as valid for form use.



@Component({
  standalone: true,
  imports: [ReactiveFormsModule],
  selector: 'app-custom-input',
  template: `
    <input [formControl]="control" />
  `,
  providers: [{
    provide: NG_VALUE_ACCESSOR,
    multi: true,
    useExisting: CustomInputComponent,
  }],
})
export class CustomInputComponent implements ControlValueAccessor, OnInit {

  control: FormControl;
  injector = inject(Injector);

  ngOnInit() {
    // ⬇ It can cause a circular dependency if injected in the constructor
    const ngControl = this.injector.get(NgControl, null, { self: true, optional: true });

    if (ngControl instanceof NgModel) {

      // ⬇ Grab the host control
      this.control = ngControl.control;

      // ⬇ Makes sure the ngModel is updated
      ngControl.control.valueChanges.subscribe((value) => {
        if (ngControl.model !== value || ngControl.viewModel !== value) {
          ngControl.viewToModelUpdate(value);
        }
      });

    } else {
      this.control = new FormControl();
    }
  }

  // ⬇ Dummy ValueAccessor methods
  writeValue() { }
  registerOnChange() { }
  registerOnTouched() { }
}


Enter fullscreen mode Exit fullscreen mode

We need to also make sure the viewToModelUpdate is called when the value changes, so that the ngModel is kept updated and that the ngModelChange is triggered.

FormControl and FormControlName

Let's have a look at how this can the extended to also work with the other directives. The formControl is the simplest one, all you need to add is this.



if (ngControl instanceof FormControlDirective) {
  this.control = ngControl.control;
}


Enter fullscreen mode Exit fullscreen mode

When using formControlName, to make sure we have the correct fromControl we need the ControlContainer, this is what keeps and manages all the controls in a form/group.

ControlContainer class diagram

So we can inject it and grab the control using the name of the control, like so.



if (ngControl instanceof FormControlName) {
  const container = this.injector.get(ControlContainer).control as FormGroup;
  this.control = container.controls[ngControl.name] as FormControl;
  return;
}


Enter fullscreen mode Exit fullscreen mode

Reuse with Directive Composition

With the 3 directives working, we can look at how to arrange this in a way that is simple to use. I think that the Directive Composition API is a good fit for this.

So if we put all the pieces together in a Directive, this is how it should look like.



@Directive({
  standalone: true,
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      multi: true,
      useExisting: HostControlDirective,
    },
  ],
})
export class HostControlDirective implements ControlValueAccessor {

  control: FormControl;

  private injector = inject(Injector);
  private subscription?: Subscription;

  ngOnInit() {
    const ngControl = this.injector.get(NgControl, null, { self: true, optional: true });

    if (ngControl instanceof NgModel) {
      this.control = ngControl.control;
      this.subscription = ngControl.control.valueChanges.subscribe((value) => {
        if (ngControl.model !== value || ngControl.viewModel !== value) {
          ngControl.viewToModelUpdate(value);
        }
      });

    } else if (ngControl instanceof FormControlDirective) {
      this.control = ngControl.control;

    } else if (ngControl instanceof FormControlName) {
      const container = this.injector.get(ControlContainer).control as FormGroup;
      this.control = container.controls[ngControl.name] as FormControl;

    } else {
      this.control = new FormControl();
    }
  }

  writeValue() { }
  registerOnChange() { }
  registerOnTouched() { }

  ngOnDestroy() {
    this.subscription?.unsubscribe();
  }
}


Enter fullscreen mode Exit fullscreen mode

And with all the code being in the reusable directive, our custom components looks very clean.



@Component({
  standalone: true,
  imports: [ReactiveFormsModule],
  selector: 'app-custom-input',
  template: `
    <input [formControl]="hcd.control" />
  `,
  hostDirectives: [HostControlDirective],
})
export class CustomInputComponent {
  hcd = inject(HostControlDirective);
}


Enter fullscreen mode Exit fullscreen mode
💖 💪 🙅 🚩
cyrillbrito
Cyrill Brito

Posted on June 5, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related