DEV Community

Cyrill Brito
Cyrill Brito

Posted on

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

Top comments (3)

Collapse
 
rhythm08 profile image
Rhythm Sharma

Amazing, Learned something new!!

Collapse
 
z2lai profile image
z2lai • Edited

From the article, I thought this meant do not "re-implement" Control Value Accessor at all when creating custom input components because Angular has already implemented it for the OOTB value accessors. But what you're really showing here is "re-implement" it once, and reuse this one implementation with NgModel (Template-driven forms), FormControlName directive and FormControlDirective (Reactive forms)! Might want to rename the title of your article because the examples and explanations you've shown here are really insightful for learning how to implement CVA across the board for all Angular Form API options

One question I had was for grabbing the control for FormControlName directive, instead of injecting the parent form ControlContainer class and getting the control via container.controls[ngControl.name], couldn't we just use the same ngControl.control approach you used for FormControlDirective considering that NgControl is provided the same way in both directive classes:
FormControlName Provider Config: github.com/angular/angular/blob/89...
FormControlDirective Provider Config: github.com/angular/angular/blob/89...

What about creating a base abstract component that implements CVA as an alternative to not re-implementing CVA more than once? All custom input components can than extend this base abstract component without having to implement CVA themselves. This article shows one way of doing that (although a bit extreme as it has two levels of inheritance): ozak.medium.com/stop-repeating-you.... I haven't seen an example of making this truly reusable with the different form modules but I suspect it would be the same implementation as yours. The only downside I see to this is that composition via directives (your approach) is preferred over inheritance in general for better maintainability.

p.s. Just saw a note in the source code that the ngModelChange output is deprecated as of v6, though it hasn't been removed from the source code yet: github.com/angular/angular/blob/89...

Collapse
 
clarityofmind profile image
Dmitrii Vasilev

Hey! Thanks for this tip. But how are you going to set writeValue function in different components ?