Control Value Accessor: Custom Form Components in Angular
Custom components controlled by a FormControl
Angular allows us to control form inputs using the FormsModule
or the ReactiveFormsModule
. With them, you can bind a FormControl to your input and control its value.
<input type="text" [(ngModel)]="name" />
<input type="text" [formControl]="nameControl" />
HTML<input type="text" [(ngModel)]="name" />
<input type="text" [formControl]="nameControl" />
But what if you create your own custom component? Like a datepicker, a star rating, or a regex input. Can you bind a FormControl
to it?
<app-datepicker [(ngModel)]="date"></app-datepicker>
<app-datepicker [formControl]="dateControl"></app-datepicker>
<app-stars [(ngModel)]="stars"></app-stars>
<app-stars [formControl]="starsControl"></app-stars>
<app-regex [(ngModel)]="regex"></app-regex>
<app-regex [formControl]="regexControl"></app-regex>
HTML<app-datepicker [(ngModel)]="date"></app-datepicker>
<app-datepicker [formControl]="dateControl"></app-datepicker>
<app-stars [(ngModel)]="stars"></app-stars>
<app-stars [formControl]="starsControl"></app-stars>
<app-regex [(ngModel)]="regex"></app-regex>
<app-regex [formControl]="regexControl"></app-regex>
Native Inputs and FormControls
Your first guess may have been to add an @Input()
in your component to receive the formControl
. That would work, but not when using formControlName
or [(ngModel)]
.
What we really want is to reuse the same logic that Angular uses for binding FormControl
s to native input elements.
If you look at the FormsModule
source code, you'll see directives for the native input elements implementing an interface called ControlValueAccessor
.
This interface is what allows the FormControl
to connect to the component.
Control Value Accessor
Let's create a simple date input component to test this out. Our component needs to implement the ControlValueAccessor
interface.
@Component({
selector: 'app-date-input',
...
})
export class DateInputComponent implements ControlValueAccessor {
public readonly dayControl = new FormControl();
public readonly monthControl = new FormControl();
public readonly yearControl = new FormControl();
}
TypeScript@Component({
selector: 'app-date-input',
...
})
export class DateInputComponent implements ControlValueAccessor {
public readonly dayControl = new FormControl();
public readonly monthControl = new FormControl();
public readonly yearControl = new FormControl();
}
This interface defines 4 methods:
writeValue(value: T | null): void
registerOnChange(onChange: (value: T | null) => void): void
registerOnTouched(onTouched: () => void)
setDisabledState(isDisabled: boolean): void
registerOnChange
receives a callback function that you need to call when the value changes. Similarly, registerOnTouched
receives a callback function that you need to call when the input is touched.
private _onChange = (value: Date | null) => undefined;
public registerOnChange(fn: (value: Date | null) => void): void {
this._onChange = fn;
}
private _onTouched = () => undefined;
public registerOnTouched(fn: () => void): void {
this._onTouched = fn;
}
public ngOnInit(): void {
combineLatest([
this.dayControl.valueChanges,
this.monthControl.valueChanges,
this.yearControl.valueChanges,
]).subscribe(([day, month, year]) => {
const fieldsAreValid =
this.yearControl.valid &&
this.monthControl.valid &&
this.dayControl.valid;
const value = fieldsAreValid ? new Date(year, month - 1, day) : null;
this._onChange(value);
this._onTouched();
});
}
TypeScriptprivate _onChange = (value: Date | null) => undefined;
public registerOnChange(fn: (value: Date | null) => void): void {
this._onChange = fn;
}
private _onTouched = () => undefined;
public registerOnTouched(fn: () => void): void {
this._onTouched = fn;
}
public ngOnInit(): void {
combineLatest([
this.dayControl.valueChanges,
this.monthControl.valueChanges,
this.yearControl.valueChanges,
]).subscribe(([day, month, year]) => {
const fieldsAreValid =
this.yearControl.valid &&
this.monthControl.valid &&
this.dayControl.valid;
const value = fieldsAreValid ? new Date(year, month - 1, day) : null;
this._onChange(value);
this._onTouched();
});
}
writeValue
is called when the FormControl
value is changed programmatically, like when you call FormControl.setValue(x)
. It can receive anything, but if you're using it correctly, it should only receive T
(T = Date
in our case) or null
.
public writeValue(value: Date | null): void {
value = value ?? new Date();
const day = value.getDate();
const month = value.getMonth() + 1;
const year = value.getFullYear();
this.dayControl.setValue(day);
this.monthControl.setValue(month);
this.yearControl.setValue(year);
}
TypeScriptpublic writeValue(value: Date | null): void {
value = value ?? new Date();
const day = value.getDate();
const month = value.getMonth() + 1;
const year = value.getFullYear();
this.dayControl.setValue(day);
this.monthControl.setValue(month);
this.yearControl.setValue(year);
}
The last method is optional. setDisabledState()
is called when the FormControl
status changes to or from the disabled state.
This method receives a single argument indicating if the new state is disabled. If it was disabled, and now it's enabled, it's called with false
. If it was enabled, and now it's disabled, it's called with true
.
public setDisabledState(isDisabled: boolean): void {
if (isDisabled) {
this.dayControl.disable();
this.monthControl.disable();
this.yearControl.disable();
} else {
this.dayControl.enable();
this.monthControl.enable();
this.yearControl.enable();
}
}
TypeScriptpublic setDisabledState(isDisabled: boolean): void {
if (isDisabled) {
this.dayControl.disable();
this.monthControl.disable();
this.yearControl.disable();
} else {
this.dayControl.enable();
this.monthControl.enable();
this.yearControl.enable();
}
}
Providing the NG_VALUE_ACCESSOR
The last step to make this work is to tell Angular that our component is ready to connect to FormControl
s.
All classes that implement the ControlValueAccessor
interface are provided through the NG_VALUE_ACCESSOR
token. Angular uses this token to grab the ControlValueAccessor
and connect the FormControl
to it.
So, we'll provide our component in this token and Angular will use it to connect to the FormControl
.
By the way, since we're providing our component before its declaration, we'll need to use Angular's forwardRef()
function to make this work.
@Component({
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => DateInputComponent),
multi: true,
},
],
...
})
export class DateInputComponent implements ControlValueAccessor { ... }
TypeScript@Component({
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => DateInputComponent),
multi: true,
},
],
...
})
export class DateInputComponent implements ControlValueAccessor { ... }
Conclusion
Everything should be working now. You can play with the code in this repository.
There's another thing I'd like to do with our custom date input: I want it to validate the inputs. February 31 is not a valid date, and we shouldn't be accepting that.
Also, I only want to accept business days. For that, we'll need a synchronous validation to see if it's a weekday and an asynchronous validation to consult an API and see if it's not a holiday.
We'll do that in another article.
Have a great day, and I'll see you soon!