Forwarding Form Controls to Custom Control Components in Angular
Sometimes we want to forward and use an existing form control rather than creating a redundant value accessor wrapper. One common use case is when creating, for example, custom input
components. The following image describes our goal:
We want to use form controls passed via formControl
, formControlName
, and ngModel
directives in our custom input
component and forward it to our internal input
element. Let’s see two ways we can do it:
Setting the Control valueAccessor property
The first option we might take is to use the NodeInjector
and retrieve a reference to our control using the NgControl
provider:
@Component({
selector: 'app-input',
template: `<input />`,
providers: [{
provide: NG_VALUE_ACCESSOR,
multi: true,
useExisting: InputComponent
}]
})
export class InputComponent implements ControlValueAccessor {
const ngControl = inject(NgControl, { self: true });
...
}
However, that won’t work, because we’ll receive a circular dependency error.
It’s possible to address this by removing the provider, injecting the NgControl
, and explicitly setting the valueAccessor
property to a noop
value accessor, as we don’t care about the value; we just want to “satisfy” Angular.
class NoopValueAccessor implements ControlValueAccessor {
writeValue() {}
registerOnChange() {}
registerOnTouched() {}
}
function injectNgControl() {
const ngControl = inject(NgControl, { self: true, optional: true });
if (!ngControl) throw new Error('...');
if (
ngControl instanceof FormControlDirective ||
ngControl instanceof FormControlName ||
ngControl instanceof NgModel
) {
// 👇👇👇
ngControl.valueAccessor = new NoopValueAccessor();
return ngControl;
}
throw new Error(`...`);
}
Now, we can use it in our input
component:
@Component({
selector: 'app-input',
standalone: true,
imports: [ReactiveFormsModule],
template: `<input [formControl]="ngControl.control" /> `,
})
export class InputComponent {
ngControl = injectNgControl();
}
Using Host Directives
The first technique works, but it always feels “hacky” since we’re essentially doing Angular’s job by setting the value accessor property. We can now leverage the host directives feature to get the same result. First, we’ll create a NoopValueAccessorDirective
:
@Directive({
standalone: true,
providers: [
{
provide: NG_VALUE_ACCESSOR,
multi: true,
useExisting: NoopValueAccessorDirective,
},
],
})
export class NoopValueAccessorDirective implements ControlValueAccessor {
writeValue(obj: any): void {}
registerOnChange(fn: any): void {}
registerOnTouched(fn: any): void {}
}
The next step is to create the same injectNgControl
function as before without setting the value accessor property:
export function injectNgControl() {
const ngControl = inject(NgControl, { self: true, optional: true });
if (!ngControl) throw new Error('...');
if (
ngControl instanceof FormControlDirective ||
ngControl instanceof FormControlName ||
ngControl instanceof NgModel
) {
return ngControl;
}
throw new Error('...');
}
Finally, we’ll use it in our input
component:
@Component({
selector: 'app-input',
standalone: true,
// 👇👇👇
hostDirectives: [NoopValueAccessorDirective],
imports: [ReactiveFormsModule],
template: ` <input [formControl]="ngControl.control" /> `,
})
export class InputComponent {
ngControl = injectNgControl();
}
Now, we can use our input
component with any control
we want:
<app-input [formControl]="control" />
<form [formGroup]="form">
<app-input formControlName="name"></app-input>
<ng-container formArrayName="skills">
<app-input [formControlName]="index"
*ngFor="let c of skills.controls; index as index"></app-input>
</ng-container>
</form>
Follow me on Medium or Twitter to read more about Angular and JS!