Introduction
Six years have passed since Angular first appeared, and developers now have a solid understanding of how the framework's pieces fit together and how to build robust applications with it.
Forms are one of Angular's foundational building blocks, showing up in our day-to-day work through account creation flows, authentication screens, and other business requirements.
This is where complexity tends to creep in, especially when a single form combines multiple FormGroup, FormControl, and FormArray instances. The result is often code that is hard to maintain and reason about.
So how do we make our forms simpler?
The answer lies in breaking down complex value structures into individual FormControl instances and leveraging the ControlValueAccessor API.
The FormControl
The FormControl class comes from the @angular/forms module. Creating an instance requires two arguments:
- an initial value
- an options object (optional)
@Component({...})
export class UserComponent implements OnInit {
firstname = new FormControl('Nicolas');
ngOnInit(): void {
console.log(this.firstname.value); // Nicolas
}
}
The initial value can be of any type, be it an object, an array, an array of objects, or something else entirely.
This means we are free to write:
@Component({...})
export class UserComponent implements OnInit {
user = new FormControl({ firstname: 'Nicolas' });
ngOnInit(): void {
console.log(this.user.value); //{ firstname: 'Nicolas'}
}
}
The ControlValueAccessor API
Essentially, the ControlValueAccessor API tells Angular how to read the value of a control. Think of it as a bridge that connects a control to a native DOM element.
How does one go about implementing this API?
Since ControlValueAccessor is just an interface, the natural step is to write:
export class AddressComponent implements ControlValueAccessor{}
Putting this interface into practice means implementing several methods:
writeValue -- model -> view
Use this method to push a new value into your element. Angular invokes it in these situations:
- 1. When your control is first initialized
- 2. Whenever you call
this.control.patchValue()orthis.control.setValue()
export class AddressComponent implements ControlValueAccessor {
writeValue(value: any): void {
/**
* Value est la valeur de votre contrôle
* Vous pouvez réaliser la logique dont vous avez besoin
* pour affecter la valeur à votre élément
*/
}
}
registerOnChange -- view -> model
This method lets you register a callback that Angular will call to update the control whenever your element changes.
Angular gives you a function and expects you to invoke it every time the element changes and the control needs to reflect that change.
export class AddressComponent implements ControlValueAccessor {
private _onChange: (x: any) => void;
writeValue(value: any): void {
/**
* Value est la valeur de votre contrôle
* Vous pouvez réaliser la logique dont vous avez besoin
* pour affecter la valeur à votre élément
*/
}
registerOnChange(fn: (x: any) => void): void {
this._onChange = fn;
}
}
registerOnTouched -- view -> model
This method works like registerOnChange, except the callback should be fired when your component has been "touched" — in other words, when the user has interacted with it.
export class AddressComponent implements ControlValueAccessor {
private _onChange: (x: any) => void;
private _onTouched: () => void;
writeValue(value: any): void {
/**
* Value est la valeur de votre contrôle
* Vous pouvez réaliser la logique dont vous avez besoin
* pour affecter la valeur à votre élément
*/
}
registerOnChange(fn: (x: any) => void): void {
this._onChange = fn;
}
registerOnTouched(fn: () => void): void {
this._onTouched = fn;
}
}
setDisabledState
This method gets invoked when the control's status changes to or from DISABLE.
Angular calls it under these circumstances:
- 1. When a control is created with the
disabledproperty set to true:new FormControl({value: null, disabled: true}). - 2. When you call
control.disable()orcontrol.enable().
export class AddressComponent implements ControlValueAccessor {
private _onChange: (x: any) => void;
private _onTouched: () => void;
writeValue(value: any): void {
/**
* Value est la valeur de votre contrôle
* Vous pouvez réaliser la logique dont vous avez besoin
* pour affecter la valeur à votre élément
**/
}
registerOnChange(fn: (x: any) => void): void {
this._onChange = fn;
}
registerOnTouched(fn: () => void): void {
this._onTouched = fn;
}
setDisabledState(isDisable: boolean): void {}
}
To wrap things up, the component must be registered as a form component by providing it through the NG_VALUE_ACCESSOR token.
@Component({
selector: 'address',
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => AddressComponent), multi: true}]
})
export class AddressComponent implements ControlValueAccessor {
private _onChange: (x: any) => void;
private _onTouched: () => void;
writeValue(value: any): void {
/**
* Value est la valeur de votre contrôle
* Vous pouvez réaliser la logique dont vous avez besoin
* pour affecter la valeur à votre élément
*/
}
registerOnChange(fn: (x: any) => void): void {
this._onChange = fn;
}
registerOnTouched(fn: () => void): void {
this._onTouched = fn;
}
setDisabledState(isDisable: boolean): void {}
}
Why destructure to structure better?
The idea is to store richer structures inside our FormControl instances and delegate the corresponding form to a child component.
Consider a user form shaped like this:
- last name
- first name
- address
- city
- country
- postal code
- street
Naturally, the form linked to that description looks like this:
@Component({...})
export class UserComponent implements OnInit {
userForm = new FormGroup({
name: new FormControl(null),
firstname: new FormControl(null),
address: new FormGroup({
city: new FormControl(null),
country: new FormControl(null),
zipCode: new FormControl(null),
street: new FormControl(null)
})
});
ngOnInit(): void {
console.log(this.userForm.value); //{ firstname: 'Nicolas'}
}
}
Even with a form this small, juggling many business rules — especially around the address block — quickly becomes unwieldy.
So why not build a dedicated component that handles only the address?
A control's value may be any kind of structure.
@Component({...})
export class UserComponent implements OnInit {
user = new FormGroup({
name: new FormControl(null),
firstname: new FormControl(null),
address: new FormControl(null)
});
ngOnInit(): void {
console.log(this.user.value); //{ name, ... }
}
}
The ControlValueAccessor API lets us bridge a control and a custom element.
@Component({
selector: 'address',
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => AddressComponent), multi: true}]
})
export class AddressComponent implements OnDestroy, ControlValueAccessor {
addressForm = new FormGroup({
city: new FormControl(null),
country: new FormControl(null),
zipCode: new FormControl(null),
street: new FormControl(null)
})
private _unsubscribe$: Subject<boolean> = new Subject();
private _onTouched: () => void;
ngOnDestroy():void {
this._unsubscribe$.next(true);
this._unsubscribe$.complete();
}
writeValue(address Adress): void {
address && this.addressForm.patchValue(address);
}
registerOnChange(fn: (x: Address) => void): void {
this.addressForm.valueChanges
.pipe(takeUntil(this._unsubscribe$))
.subscribe(address => {
fn(address);
this._onTouched();
})
}
registerOnTouched(fn: () => void): void {
this._onTouched = fn;
}
setDisabledState(isDisable: boolean): void {}
}
Inside registerOnChange, we subscribe to the valueChanges observable, which emits the form's latest value whenever a field is updated.
Every update triggers the control's change-notification callback.
Within the template of UserComponent, writing the form becomes straightforward:
<form [formGroup]="userForm">
<input type="text" formControlName="name" />
<input type="text" formControlName="firstname" />
<address formControlName="address"></address>
</form>
The address control then holds, at every change coming from AddressComponent's form, the value:
{ city, country, street, zipCode }
Trade-offs to weigh?
The upsides are compelling:
- your form logic gets simpler
- parts of the form become atomic
- the custom form component is reusable
The main drawback remains the custom component's design. No matter where that component is dropped, its look stays fixed and isn't easily altered.
