Angular FormValueControl: The Signal-Based Successor to ControlValueAccessor
⚡ FormValueControl — Custom Form Controls Without the Boilerplate
FormValueControl is the signal-based interface for building custom form controls in Angular's new Signal Forms (@angular/forms/signals). It is the modern successor to ControlValueAccessor (CVA).
Where CVA forces you to implement four imperative methods (writeValue, registerOnChange, registerOnTouched, setDisabledState) plus a forwardRef + NG_VALUE_ACCESSOR provider dance, FormValueControl needs just one line: a value model signal. Angular does the two-way binding for you.
🧪 Status: Signal Forms and
FormValueControlare experimental (developer preview, Angular 21+). The API may change — pin your Angular version.
😤 The Problem with ControlValueAccessor
To make a custom input work with reactive forms today, CVA requires:
@Component({
selector: 'app-custom-input',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CustomInputComponent), // 😩 forwardRef
multi: true,
},
],
template: `<input [value]="value" (input)="onInput($event)" (blur)="onTouched()" />`,
})
export class CustomInputComponent implements ControlValueAccessor {
value = '';
private onChange = (v: string) => {}; // 😩 callback plumbing
private onTouched = () => {};
writeValue(v: string) { this.value = v ?? ''; } // 😩 method 1
registerOnChange(fn: any) { this.onChange = fn; } // 😩 method 2
registerOnTouched(fn: any) { this.onTouched = fn; } // 😩 method 3
setDisabledState(d: boolean) { /* ... */ } // 😩 method 4
onInput(e: Event) { this.onChange((e.target as HTMLInputElement).value); }
}
That's a lot of ceremony for "a component that holds a value."
✨ The Signal-Based Solution
With FormValueControl, the same control collapses to this:
import { Component, model } from '@angular/core';
import { FormValueControl } from '@angular/forms/signals';
@Component({
selector: 'app-custom-input',
standalone: true,
template: `
<input
[value]="value()"
(input)="value.set($any($event.target).value)"
/>
`,
})
export class CustomInputComponent implements FormValueControl<string> {
// 🎉 That's the whole contract — a value model signal.
value = model<string>('');
}
No forwardRef. No NG_VALUE_ACCESSOR. No four methods. No manual callbacks. The value model signal is the two-way channel: Angular reads it to display data and writes to it when the field changes.
🧩 The FormValueControl Interface — All Features
The only required member is value. Every other member is optional — implement one and Angular automatically wires that capability to the bound field. All of them are signals.
| Member | Signal type | Direction | Purpose |
|---|---|---|---|
value | model<T>() | ⇄ two-way | Required. The control's value. |
errors | input<ValidationError[]>() | field → control | Validation errors for this control, so you can render them. |
disabled | input<boolean>() | field → control | Whether the field is disabled. |
disabledReasons | input<readonly DisabledReason[]>() | field → control | Why it's disabled (for tooltips/UX). |
readonly | input<boolean>() | field → control | Whether the field is read-only. |
hidden | input<boolean>() | field → control | Whether the field is hidden. |
invalid | input<boolean>() | field → control | Convenience flag: does the field have errors? |
pending | input<boolean>() | field → control | Async validation in progress. |
touched | model<boolean>() | ⇄ two-way | Whether the user has interacted; set it from the control on blur. |
dirty | model<boolean>() | ⇄ two-way | Whether the value changed from its initial. |
name | input<string>() | field → control | The generated control name (for id/for wiring). |
required | input<boolean>() | field → control | Metadata from a required validator. |
min / max | input<number>() | field → control | Metadata from min/max validators. |
minLength / maxLength | input<number>() | field → control | Metadata from length validators. |
pattern | input<readonly RegExp[]>() | field → control | Metadata from pattern validators. |
💡 The mental shift: with CVA you pushed and pulled values through callbacks. With
FormValueControlyou declare signals, and Angular keeps them in sync with the field automatically.
🔗 How Angular Binds It — the [control] Directive
In Signal Forms you build a form with the form() function, which produces a tree of Field objects. You bind a field to your custom control with the [control] directive:
<app-custom-input [control]="userForm.username" />
Angular then:
- Reads your
value()to display the field's value, and writes user edits back into the field. - Feeds
errors,disabled,required, etc. into your matching input signals (if you declared them). - Reads your
touched/dirtymodels to update field state.
🚀 Full Example — Custom Input in a Signal Form
1. The custom control
import { Component, model, input } from '@angular/core';
import { FormValueControl, ValidationError } from '@angular/forms/signals';
@Component({
selector: 'app-text-field',
standalone: true,
template: `
<label>
<input
[value]="value()"
[disabled]="disabled()"
(input)="value.set($any($event.target).value)"
(blur)="touched.set(true)"
/>
</label>
@if (touched() && errors().length) {
<p class="error">{{ errors()[0].message }}</p>
}
`,
styles: [`.error { color: #c0392b; font-size: 0.85rem; }`],
})
export class TextFieldComponent implements FormValueControl<string> {
// Required
value = model<string>('');
// Optional capabilities — declare only what you use
errors = input<ValidationError[]>([]);
disabled = input(false);
touched = model(false);
}
2. The form using it
import { Component } from '@angular/core';
import { Control, form, required, minLength } from '@angular/forms/signals';
import { signal } from '@angular/core';
import { TextFieldComponent } from './text-field.component';
@Component({
selector: 'app-signup',
standalone: true,
imports: [Control, TextFieldComponent],
template: `
<h2>Sign Up</h2>
<!-- Bind the field to the custom control -->
<app-text-field [control]="signupForm.username" />
<app-text-field [control]="signupForm.email" />
<p>Form value: {{ signupForm().value() | json }}</p>
<p>Valid: {{ signupForm().valid() }}</p>
<button [disabled]="!signupForm().valid()">Submit</button>
`,
})
export class SignupComponent {
// The raw model data as a signal
private model = signal({ username: '', email: '' });
// Build a signal form with schema/validators
signupForm = form(this.model, (path) => {
required(path.username, { message: 'Username is required' });
minLength(path.username, 3, { message: 'At least 3 characters' });
required(path.email, { message: 'Email is required' });
});
}
That's it — validation errors flow into errors(), touched drives when to show them, and the value stays in sync both ways with zero CVA boilerplate.
⭐ Richer Example — Star Rating Control
Custom widgets (where a native <input> can't help) are where this shines:
import { Component, model, input } from '@angular/core';
import { FormValueControl, ValidationError } from '@angular/forms/signals';
@Component({
selector: 'app-star-rating',
standalone: true,
template: `
<div class="stars" [class.disabled]="disabled()">
@for (star of stars; track star) {
<span
class="star"
(click)="rate(star)"
(mouseenter)="hover.set(star)"
(mouseleave)="hover.set(0)"
>
{{ (hover() || value()) >= star ? '★' : '☆' }}
</span>
}
</div>
@if (touched() && errors().length) {
<small class="error">{{ errors()[0].message }}</small>
}
`,
styles: [`
.star { cursor: pointer; font-size: 1.8rem; color: #f5a623; }
.disabled .star { pointer-events: none; opacity: 0.5; }
.error { color: #c0392b; }
`],
})
export class StarRatingComponent implements FormValueControl<number> {
readonly stars = [1, 2, 3, 4, 5];
value = model<number>(0);
errors = input<ValidationError[]>([]);
disabled = input(false);
touched = model(false);
// Local UI-only state (not part of the form contract)
hover = model(0);
rate(star: number): void {
this.value.set(star); // updates the field
this.touched.set(true); // mark interacted
}
}
Usage in a form:
reviewForm = form(signal({ rating: 0 }), (path) => {
min(path.rating, 1, { message: 'Please pick at least 1 star' });
});
<app-star-rating [control]="reviewForm.rating" />
☑️ Checkbox Variant — FormCheckboxControl
For boolean, checkbox-like controls, Angular provides a sibling interface FormCheckboxControl that uses a checked model instead of value:
import { Component, model, input } from '@angular/core';
import { FormCheckboxControl } from '@angular/forms/signals';
@Component({
selector: 'app-toggle',
standalone: true,
template: `
<button
role="switch"
[attr.aria-checked]="checked()"
[disabled]="disabled()"
(click)="checked.set(!checked())"
>
{{ checked() ? 'ON' : 'OFF' }}
</button>
`,
})
export class ToggleComponent implements FormCheckboxControl {
checked = model(false); // required member for checkbox controls
disabled = input(false);
}
🆚 ControlValueAccessor vs FormValueControl
| Aspect | ControlValueAccessor | FormValueControl |
|---|---|---|
| Package | @angular/forms | @angular/forms/signals |
| Required boilerplate | 4 methods + forwardRef + provider | 1 value model signal |
forwardRef / NG_VALUE_ACCESSOR | ✅ Required | ❌ Not needed |
| Value sync | Manual callbacks | Automatic via model() |
| Disabled state | setDisabledState() method | disabled = input() |
| Touched state | registerOnTouched callback | touched = model() |
| Validation errors in control | Not provided directly | errors = input() |
Validator metadata (required, min…) | Not available | Optional input signals |
| Binding in template | formControlName / ngModel | [control] directive |
| Change detection | Zone-based | Signal-based (zoneless-friendly) |
| Maturity | ✅ Stable | 🧪 Experimental |
✅ Features & Benefits Summary
- Minimal contract — only
value = model<T>()is required. - No
forwardRef/NG_VALUE_ACCESSOR— the biggest CVA papercut is gone. - Opt-in capabilities — add
errors,disabled,touched,required,min,pattern, etc. only when you need them. - Errors delivered to the control — render field-specific validation messages inside the component itself.
- Two-way
touched/dirtyas models — no callback registration. - Signal-native & zoneless-friendly — integrates with the rest of the signals ecosystem and fine-grained reactivity.
- Type-safe —
FormValueControl<T>is generic over the value type.
⚠️ Caveats & Best Practices
- 🧪 Experimental: Signal Forms are in developer preview — expect API changes and pin your version.
- 🎯 Declare only what you use: every optional signal you add makes Angular wire that capability; skip the ones you don't need.
- 🖊️ Set
touchedyourself (e.g. onbluror first interaction) so error messages appear at the right time. - 🚫 Don't mix with CVA on the same component — pick one forms system per control.
- 🔤 Use
checked(FormCheckboxControl) for boolean toggles/checkboxes instead ofvalue. - ♿ Keep accessibility in mind — wire
name(),required(), anddisabled()into proper ARIA attributes.
🎯 Summary
FormValueControl is Angular's signal-based replacement for ControlValueAccessor. Instead of four imperative methods plus a forwardRef/NG_VALUE_ACCESSOR provider, you implement a single value = model<T>() and optionally declare signal inputs like errors, disabled, and touched. Angular binds it to a Signal Forms Field via the [control] directive and keeps everything in sync automatically. The result is dramatically less boilerplate, first-class validation-error access inside the control, and full alignment with Angular's signal-driven, zoneless future. ⚡