Writing Your Own Form Control in Angular: FormValueControl, FormCheckboxControl and ControlValueAccessor in Existing Code
In Angular 22 a value = model() is all a component needs to be a form control: we write a quantity stepper and a gift wrap switch with FormValueControl and FormCheckboxControl, use the same control with Reactive Forms and ngModel, and read and migrate old ControlValueAccessor code.
Version note: The examples in this post were run with Angular 22.2, TypeScript 6.0, Vitest 4.1 and Playwright 1.65.
Guess First
bookstore-web, the Angular client of Bookstore, the online bookstore used as the shared example across this site's series, has had a star rating component since post 24. In post 26 we turned its @Input/@Output pair into model():
export class BookRatingComponent {
max = input(5);
compact = input(false, { transform: booleanAttribute });
disabled = input(false, { transform: booleanAttribute });
value = model(0);
rated = output<number>();
// ...
}
This post is about writing your own form control, and in Signal Forms a model() named value is all a component needs to be one. If you only add implements FormValueControl<number> to the class, does it compile? The answer comes shortly, with real compiler output.
In post 32 the quantities in the checkout form were plain type="number" inputs. In this post we turn them into a quantity stepper with −/+ buttons, add an on/off switch for gift wrapping, and read the ControlValueAccessor you will meet in existing projects.
FormValueControl: A Quantity Stepper
@Component({
selector: 'app-quantity-stepper',
styleUrl: './quantity-stepper.component.css',
host: { role: 'group', '[attr.aria-label]': 'label()', '(focusout)': 'onFocusOut($event)' },
template: `
<button type="button" aria-label="Decrease quantity"
[disabled]="disabled() || atMin()" (click)="step(-1)">−</button>
<input type="number" [attr.aria-label]="label()" [value]="value()"
[disabled]="disabled()" [attr.aria-invalid]="invalid()"
(input)="onInput($event)" />
<button type="button" aria-label="Increase quantity"
[disabled]="disabled() || atMax()" (click)="step(1)">+</button>
`
})
export class QuantityStepperComponent implements FormValueControl<number> {
// Signal Forms fills these from the field state
readonly value = model(1);
readonly disabled = input(false);
readonly invalid = input(false);
readonly errors = input<readonly ValidationError[]>([]);
readonly min = input<number | undefined>(undefined);
readonly max = input<number | undefined>(undefined);
readonly touch = output<void>();
readonly label = input('Quantity');
protected readonly atMin = computed(() => this.min() !== undefined && this.value() <= this.min()!);
protected readonly atMax = computed(() => this.max() !== undefined && this.value() >= this.max()!);
protected step(delta: number): void {
this.value.update((q) => q + delta);
}
protected onInput(event: Event): void {
const raw = (event.target as HTMLInputElement).valueAsNumber;
this.value.set(Number.isNaN(raw) ? 0 : raw);
}
protected onFocusOut(event: FocusEvent): void {
// focus left the whole component: report touched
const host = event.currentTarget as HTMLElement;
if (!host.contains(event.relatedTarget as Node | null)) {
this.touch.emit();
}
}
}
The only required member of FormValueControl<number> is value: a ModelSignal<number>. The [formField] directive binds that model to the field's value in both directions. When the component calls value.update(...), the value goes to the form, and when the form's model changes, it comes back to the component. You don't write a single line of glue in between.
The change in the checkout template is two lines:
@for (line of checkoutForm.lines; track line) {
<span>{{ line.title().value() }}</span>
<app-quantity-stepper [formField]="line.quantity" [label]="'Quantity of ' + line.title().value()" />
<app-field-errors [field]="line.quantity" />
}
State from the Schema: min, disabled, invalid and touch
All the other properties are optional and come from the FormUiControl interface: disabled, readonly, hidden, invalid, errors, pending, touched, dirty, required, min, max, minLength, maxLength, pattern, name. If the component declares an input with one of these names, [formField] writes the field's current state into it. The ones you don't declare are ignored.
In practice this means the following: the schema in post 32 had a min(line.quantity, 1) rule. The stepper doesn't know that rule separately; it receives 1 through its min input and disables the "−" button while the quantity is 1. The rule is written in one place, and both validation and the UI read from it. This is how we checked it in a Vitest test:
expect(buttons(el)[0].disabled).toBe(true);
buttons(el)[1].click();
await fixture.whenStable();
expect(fixture.componentInstance.line()).toEqual({ quantity: 2 });
expect(buttons(el)[0].disabled).toBe(false);
touched works differently. You can receive the touched flag from [formField] as an input, but it is the control itself that makes the field touched: you declare an output named touch and emit it when the user leaves the control. The note in Angular's type definitions says to do this on blur, not on focus. The stepper has three focusable elements; if we emitted on the blur of each, moving from "−" to the input would already mark the field as touched. So we listen to focusout on the host and fire touch only when focus leaves the component as a whole.
FormCheckboxControl: A Gift Wrap Switch
Controls whose value is true/false have their own interface. The difference is that it asks for a model named checked instead of value:
@Component({
selector: 'app-gift-wrap-toggle',
template: `
<button type="button" role="switch" [attr.aria-checked]="checked()"
[disabled]="disabled()" (click)="checked.set(!checked())" (blur)="touch.emit()">
Gift wrap: {{ checked() ? 'on' : 'off' }}
</button>
`
})
export class GiftWrapToggleComponent implements FormCheckboxControl {
readonly checked = model(false);
readonly disabled = input(false);
readonly touch = output<void>();
}
We added giftWrap: boolean to the checkout model and a schema rule that turns gift wrapping off for store pickup:
disabled(order.giftWrap, {
when: ({ valueOf }) => valueOf(order.shipping) === 'pickup' && 'Gift wrap is not available for store pickup'
});
After a click in the browser, Playwright's accessibility tree showed switch "Gift wrap: on" [checked]; switching delivery to "pickup" disabled the button. A control can't have both value and checked: it is either a FormValueControl or a FormCheckboxControl.
Connecting an Existing Component: the max Clash
Now the question from the start. We added implements FormValueControl<number> to BookRatingComponent and ran ng test, and the build stopped with this error:
✘ [ERROR] TS2416: Property 'max' in type 'BookRatingComponent' is not assignable to the same property in base type 'FormValueControl<number>'.
Type 'InputSignal<number>' is not assignable to type 'InputSignal<number | undefined> | InputSignalWithTransform<number | undefined, unknown> | undefined'.
In the component, max meant "how many stars to draw"; in FormUiControl, max means "the largest allowed value" and has to accept undefined. What if we hadn't written the implements line? Then the template that binds it with [formField] failed with TS2322, because the directive tries to write the field's number | undefined value into the max input. So even without the interface, Angular uses the same names.
The fix is a rename. We renamed max to starCount (nothing in the project bound [max]), added a touch output so the field can become touched, and emitted it on the blur of every star button. The disabled input was declared with a booleanAttribute transform; the interface also accepts an InputSignalWithTransform, so we left it as it was. After that, the component bound with [formField] drew five stars with four filled, and clicking the second star set the model to { stars: 2 }.
When you write your own control, pick input names with this list in mind: don't call something min, max, name, required or pattern unless it means what the form means by it.
The Same Component with Reactive Forms and ngModel
The custom controls guide on angular.dev (as we read it on 15 November 2026) says these controls work with Signal Forms, Reactive Forms and template-driven forms without any extra compatibility code. We tested that for the stepper:
@Component({
imports: [QuantityStepperComponent, ReactiveFormsModule],
template: `<form [formGroup]="group"><app-quantity-stepper formControlName="quantity" /></form>`
})
class ReactiveHost {
readonly group = new FormGroup({ quantity: new FormControl(3, { nonNullable: true }) });
}
@Component({
imports: [QuantityStepperComponent, FormsModule],
template: `<app-quantity-stepper [(ngModel)]="quantity" />`
})
class TemplateHost {
quantity = 2;
}
In the reactive host the input showed "3", "+" changed the FormControl's value to 4, control.touched returned true after focusout, and control.disable() disabled the buttons. In the ngModel host the input showed "2" and "−" set the property to 1. The stepper has no ControlValueAccessor at all. This is what we meant in post 32 by "you can migrate one screen at a time": you write the new control once, and screens still on Reactive Forms can use it too.
ControlValueAccessor in Existing Code
You will see code like this in every custom control written before Angular 22. The same stepper as it looked back then:
@Component({
selector: 'app-legacy-quantity-stepper',
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => LegacyQuantityStepperComponent),
multi: true
}],
template: `
<button type="button" [disabled]="disabled()" (click)="step(-1)" (blur)="onTouched()">−</button>
<span>{{ quantity() }}</span>
<button type="button" [disabled]="disabled()" (click)="step(1)" (blur)="onTouched()">+</button>
`
})
export class LegacyQuantityStepperComponent implements ControlValueAccessor {
protected readonly quantity = signal(1);
protected readonly disabled = signal(false);
private onChange: (value: number) => void = () => {};
protected onTouched: () => void = () => {};
// form → component: update the view only, never call onChange
writeValue(value: number | null): void {
this.quantity.set(value ?? 1);
}
registerOnChange(fn: (value: number) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled.set(isDisabled);
}
protected step(delta: number): void {
this.quantity.update((q) => Math.max(1, q + delta));
// component → form
this.onChange(this.quantity());
}
}
To read it you only need to recognise four methods and one provider. The NG_VALUE_ACCESSOR provider tells formControl, formControlName or ngModel "I manage this element's value"; forwardRef lets it refer to the class before the class is defined. writeValue is the direction from the form to the component. Through registerOnChange and registerOnTouched the form hands you two callbacks; you call one when the value changes and the other when the user leaves. setDisabledState is where control.disable() reaches the component.
The migration mapping is direct: the writeValue and onChange pair becomes a single value = model(), setDisabledState becomes a disabled input, onTouched becomes a touch output. The provider and forwardRef disappear completely. Keep the test that shows the old component working with formControl, run the same test against the new component, and you will see that the migration didn't change the behaviour.
Keyboard, Labels and Accessibility
When you write your own control, you also take over the keyboard and screen reader support the browser gives you for free. To keep that small in the stepper, we left the value in the middle as a native type="number" input. Playwright's accessibility tree:
- group "Quantity of Dune":
- button "Decrease quantity" [disabled]: −
- spinbutton "Quantity of Dune": "1"
- button "Increase quantity": +
Focusing the input and pressing the up arrow raised the value from 3 to 4; we didn't write that, it came from the native input. Focusing "+" and pressing Enter twice raised the value from 1 to 3. The tab order was "Quantity of Dune","Increase quantity","Quantity of Emma"; the disabled "−" button wasn't in it.
Labels need separate care. An outer <label> can't name a component that contains more than one focusable element on its own. We passed the label input as aria-label to both the role="group" host and the inner input.
BookRatingComponent, written from scratch, looked worse at first: the tree had five button "★" entries, and a screen reader user couldn't hear which one meant how many stars. We added [attr.aria-label]="'Rate ' + star + ' of ' + starCount()" to every button, and the tree became button "Rate 1 of 5" … button "Rate 5 of 5". For controls that need listbox, combobox or tabs behaviour, look at the Angular Aria directives from post 30 or the Angular CDK instead of writing the keyboard interaction from scratch. The Angular Material and CDK series covers them in detail; ARIA attributes themselves are the subject of the Accessibility (a11y) series.
In Production
After we added the new components, ng build gave a budget warning for the first time:
▲ [WARNING] bundle initial exceeded maximum budget. Budget 500.00 kB was not met by 1.83 kB with a total of 501.83 kB.
Most people who open the home page don't need the checkout page. We made the route lazy with loadComponent:
{
path: 'checkout',
loadComponent: () => import('./checkout-form/checkout-form.component').then(m => m.CheckoutFormComponent),
title: 'Checkout'
},
The initial bundle dropped to 449.20 kB; the checkout and Signal Forms code moved into a separate 52.67 kB chunk. If you are writing a control library, also remember this: FormUiControl has an optional focus() method. According to the description in the type definitions, if you don't implement it, Signal Forms tries to focus the host element when asked to focus the control. The stepper's host is not a focusable element; for a component like that, a short focus() that focuses the inner input makes sense.
Common Pitfalls
We put the three mistakes we see most often when reading ControlValueAccessor code in old projects into one component: onTouched is never called, onChange is called inside writeValue, and there is no setDisabledState. The values read in the test:
"buttonDisabled": false,
"dirtyAfterInit": false,
"dirtyAfterSetValue": true,
"emitted": [
7,
7,
7,
],
"touched": false,
The code called control.setValue(7), and the control became dirty although the user touched nothing; a guard that warns about unsaved changes would fire on this screen for no reason. valueChanges emitted the same value three times instead of once. touched stayed false even though the user left the button; if your error message is shown "when touched", the user never sees the error. After control.disable() the button stayed clickable.
On the Signal Forms side the equivalents are fewer but they exist: not declaring the touch output causes the same "error never shows" problem, and declaring a disabled input without using it in the template silently ignores the rule. And there is the name clash we saw above.
A Small Experiment
★☆☆ Connect a max rule to the stepper: add max(line.quantity, 10) to the schema and see that "+" is disabled once the quantity reaches 10. You won't have to change anything in the stepper's code.
★★☆ Write a unit picker for institutional orders: a FormValueControl<string> that holds the name of the institution's unit (for example "Main Campus" or "Library") and wraps a native select. Test the same component with both [formField] and formControlName.
★★★ Copy the test of LegacyQuantityStepperComponent and run it against QuantityStepperComponent. Note which lines you had to change; in a real project that list becomes your migration checklist.
Every text in the checkout is in English. Most of Bookstore's customers are in Türkiye. In the next post we make the UI Turkish and English with @angular/localize, extract the translation file with ng extract-i18n and build a separate bundle for each language.