A custom control is a component that behaves like an <input> in a form: it has a value, can be disabled, and knows when it is invalid or touched. Angular has two contracts: FormValueControl for Signal Forms (simple, based on model()) and the classic ControlValueAccessor for Reactive / Template-driven forms.
| FormValueControl (v22) | ControlValueAccessor | |
|---|---|---|
| Used with | [formField] (Signal Forms) | formControl, formControlName, ngModel |
| Value | value = model<T>() | writeValue + registerOnChange |
| States | optional inputs: disabled, invalid, errors, touched, required… | only setDisabledState |
| Provider | none | NG_VALUE_ACCESSOR + forwardRef |
| Lines of code | ~5 | ~20 |
FormValueControl: the Signal Forms version
The only required member is value as a model(). Any other state (disabled, invalid, errors, required, min/max...) you declare as an input() only if you need it — the [formField] directive fills it in automatically from the form’s schema.
import { Component, booleanAttribute, input, model, output } from '@angular/core';
import { FormValueControl } from '@angular/forms/signals';
@Component({
selector: 'app-rating-control',
host: { '[class.invalid]': 'invalid() && touched()', '(focusout)': 'touch.emit()' },
template: `
@for (star of [1, 2, 3, 4, 5]; track star) {
<button type="button" [class.on]="star <= value()" [disabled]="disabled()" (click)="value.set(star)">★</button>
}
`,
})
export class RatingControl implements FormValueControl<number> {
readonly value = model(0); // the only required member
readonly disabled = input(false, { transform: booleanAttribute }); // optional: declare only
readonly invalid = input(false, { transform: booleanAttribute }); // what you use
readonly touched = input(false, { transform: booleanAttribute });
readonly touch = output<void>(); // v22: “the user touched it”
}valid: false · touched: false · model: {
"rating": 0,
"comment": ""
}Boolean controls
// For boolean controls there is FormCheckboxControl (the property is `checked`, not `value`)
import { FormCheckboxControl } from '@angular/forms/signals';
export class Switch implements FormCheckboxControl {
readonly checked = model(false);
readonly disabled = input(false);
}
// <app-switch [formField]="settings.newsletter" />ControlValueAccessor: the classic version
If your application still uses Reactive Forms (or you have a component library that must work everywhere), you implement ControlValueAccessor. There are four methods through which the form and the control talk to each other.
import { forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'app-rating-cva',
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RatingCva), multi: true }],
host: { '(focusout)': 'onTouched()' },
template: `
@for (star of [1, 2, 3, 4, 5]; track star) {
<button type="button" [class.on]="star <= value()" [disabled]="disabled()" (click)="select(star)">★</button>
}
`,
})
export class RatingCva implements ControlValueAccessor {
protected readonly value = signal(0);
protected readonly disabled = signal(false);
private onChange: (v: number) => void = () => {};
protected onTouched: () => void = () => {};
writeValue(v: number | null) { this.value.set(v ?? 0); } // form → control
registerOnChange(fn: (v: number) => void) { this.onChange = fn; }
registerOnTouched(fn: () => void) { this.onTouched = fn; }
setDisabledState(d: boolean) { this.disabled.set(d); }
protected select(star: number) {
this.value.set(star);
this.onChange(star); // control → form
}
}
// <app-rating-cva [formControl]="rating" /> or formControlName="rating" or [(ngModel)]Interoperability
Compatibility (v22):
Control Signal Forms ([formField]) Reactive / Template forms
──────────────────────────────────────────────────────────────────────────────────────
FormValueControl / Checkbox ✓ native ✓ (template & reactive support for FVC)
ControlValueAccessor ✓ (CVA interop) ✓ native
<input>, <select>, <textarea> ✓ ✓
ngNoCva: an attribute that disables an automatically applied CVA on an element (v22).When YES, when NO
- An input UI that doesn’t exist natively: rating, tag input, colour picker, date range, editor.
- A native input “dressed up” that must bind directly to the form (e.g. a currency input with formatting).
- You want validation, the disabled state and touched to work automatically.
- You only style an
<input>→ put[formField]on the native input inside a layout component (label + error) and project it. - A group of fields (an address) → better a sub-form / a reusable schema (
apply), not a control.
Common pitfalls
- CVA without
multi: trueor withoutforwardRef→ errors that are hard to understand at runtime. onChangecalled insidewriteValue→ a loop, or a form marked dirty without the user doing anything.- Not marking touched: errors “after you touched the field” never appear. Emit
touch(FVC) or callonTouched(CVA) on blur. - Accessibility: a custom control must be focusable and have the correct ARIA roles (see Angular Aria).