Cyrill Brito
Posted on June 5, 2023
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;
}
}
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...
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
.
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() { }
}
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;
}
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.
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;
}
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();
}
}
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);
}
Posted on June 5, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.