When I started developing with Angular, I struggled to complete a task where I had to create several input fields for compiling a weekly timesheet.
The main problem was the need to constantly convert timesheet entries from the common HH:MM
format to a float number format for calculations. I also needed to address the inverse problem: converting float numbers received from the backend back into the HH:MM
format to populate the entries during page load.
Initially, I solved the problem using RxJs. However, now that I am more confident with Angular’s functionalities, I’ve tried a different approach that, in my opinion, ensures better encapsulation and separation of concerns. This approach involves creating a custom input that leverages the ControlValueAccessor
interface to create a custom reactive control.
The ControlValueAccessor
interface involves implementing four methods to enable communication between your custom form control and Angular’s Reactive Forms APIs. These methods are:
writeValue(newControlValue)
→This method is invoked whenever the value of the Angular reactive FormControl
changes. Its purpose is to notify your custom control when a value is updated in the model and needs to be reflected in the view. For example, the writeValue() method is invoked when you first initialize the formControl, like parentControl = new FormControl<number>(0, [Validators.required])
or when you call parentControl.setValue(4)
.registerOnChange(fn)
→ In my opinion, this is the most difficult method of the interface to understand. When the FormControl
is initially created, registerOnChange(fn)
is invoked with an argument, conventionally named fn
. This argument is a function that we need to call to notify a change in value from the view to the model. To make things clear, in most cases you should implement the following steps in your custom formControls:FormControl
to store a new value. See the example below:export class MyCustomInput implements ControlValueAccessor { //...other methods //onChange is the class property were you will store fn onChange!: (value: yourInputType) => void; //Step 1 registerOnChange(fn) { this.onChange = fn; } //Step 2 (event binding in the template.html to this method) onUserInput(newUserValue: T) { //Step 3 this.onChange(newUserValue); }
registerOnTouched(fn)
→Once you understand how registerOnChange()
works, this one will be as easy as eating candy. It works similarly: it registers the fn
callback, which you need to store in a class property, and then invoke to notify the parent FormControl that the custom control has been touched.setDisableState(isDisabled)
→ The implementation of this method is optional. It is invoked whenever the status of the parent FormControl
changes between DISABLED
and another state (remember that INVALID
still means the control is enabled) and vice versa. The argument isDisabled
is a boolean set to true
when the parent control's disable()
function is invoked, and false
when enable()
is invoked.Now that we have a clear understanding of how ControlValueAccessor
works, let’s see some code in action. If you have any feedback about it please let me know in the comments:
//timehseet.component.ts implements ControlValueAccessor, OnDestroy, AfterViewInit { readonly #timeEntryRegex = /[0-2]*[0-9]+:[0-5]{1}([0-9]{1})?/; #destroy: Subject<void> = new Subject(); #injector = inject(Injector); #parentControl!: AbstractControl | null; invalidClasses = input<string[]>(); innerControl = new FormControl<string>('', { updateOn: 'blur' }); isInvalid = false; isDisabled = false; isTouched = false; onTouch: (() => void) | undefined; writeValue(timeEntry: number | null): void { this.innerControl.setValue(this.fromFloatToString(timeEntry)); } registerOnChange(fn: any): void { this.innerControl.valueChanges .pipe( takeUntil(this.#destroy), tap(() => { this.isTouched = true; this.onTouch && this.onTouch(); }), map((rawEntry) => this.convertEntry(rawEntry)) ) .subscribe((formattedEntry) => { this.innerControl.setValue( this.fromFloatToString(formattedEntry) || '', { emitEvent: false, } ); fn(formattedEntry); }); } registerOnTouched(fn: any): void { this.onTouch = fn; } setDisabledState(isDisabled: boolean): void { isDisabled ? this.innerControl.disable() : this.innerControl.enable(); } markAsTouched() { this.isTouched = true; this.#parentControl?.updateValueAndValidity({ onlySelf: true, }); this.onTouch && this.onTouch(); } ngOnDestroy(): void { this.#destroy.next(); this.#destroy.complete(); } ngAfterViewInit(): void { this.#parentControl = this.#injector.get(NgControl).control; this.#parentControl?.statusChanges .pipe( takeUntil(this.#destroy), filter(() => this.isTouched), map((status) => status === 'INVALID'), filter((isInvalid) => isInvalid !== this.isInvalid) ) .subscribe((isInvalid: boolean) => { this.isInvalid = isInvalid; }); } private convertEntry(rawEntry: string | null): number | null { //converts the inputEntry from a string to a number if the entry is valid. //returns null otherwise } private fromStringToFloat(timesheetEntry: string) { //.... } private fromFloatToString(entryHours: number | null) { //... } } <!-- timesheet.component.html--> <input [ngClass]="isInvalid ? invalidClasses() : ''" type="text" class="form-control" (blur)="markAsTouched()" [formControl]="innerControl" />
As you can see, I’ve basically created an inner FormControl
that is responsable of handling the text format of the timesheet entry.
writeValue()
function is invoked, I simply set a new value for innerControl after converting it through the utility function fromFloatToString()
formattedEntry
variable. Then I set the innerControl
value to the newly generated value in the HH:MM
format (or an empty string if the entered value was invalid), while simultaneously notifying the parentControl
to update its value to the float number generated by formattedEntry
by invoking fn(formattedEntry)
.@Component({ selector: 'app-timesheet-input', standalone: true, imports: [ReactiveFormsModule, NgClass], templateUrl: './timesheet-input.component.html', styleUrl: './timesheet-input.component.css', providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TimesheetInputComponent), multi: true, }, ], })
This code will enable your custom component to receive a formControlName
directive. For more information about DI of a custom formControl, check this resource.