DEV Community

Lucas Paganini
Lucas Paganini

Posted on • Originally published at lucaspaganini.com

Control Value Accessor: Custom Form Components in Angular

Custom components controlled by a FormControl.


See this and many other articles at lucaspaganini.com

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" />
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 FormControls 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();
}
Enter fullscreen mode Exit fullscreen mode

This interface defines 4 methods:

  1. writeValue(value: T | null): void
  2. registerOnChange(onChange: (value: T | null) => void): void
  3. registerOnTouched(onTouched: () => void)
  4. 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();
  });
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

Providing the NG_VALUE_ACCESSOR

The last step to make this work is to tell Angular that our component is ready to connect to FormControls.

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 { ... }
Enter fullscreen mode Exit fullscreen mode

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!

References

  1. Repository GitHub

Discussion (0)