Skip to main content

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 FormValueControl are 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.

MemberSignal typeDirectionPurpose
valuemodel<T>()⇄ two-wayRequired. The control's value.
errorsinput<ValidationError[]>()field → controlValidation errors for this control, so you can render them.
disabledinput<boolean>()field → controlWhether the field is disabled.
disabledReasonsinput<readonly DisabledReason[]>()field → controlWhy it's disabled (for tooltips/UX).
readonlyinput<boolean>()field → controlWhether the field is read-only.
hiddeninput<boolean>()field → controlWhether the field is hidden.
invalidinput<boolean>()field → controlConvenience flag: does the field have errors?
pendinginput<boolean>()field → controlAsync validation in progress.
touchedmodel<boolean>()⇄ two-wayWhether the user has interacted; set it from the control on blur.
dirtymodel<boolean>()⇄ two-wayWhether the value changed from its initial.
nameinput<string>()field → controlThe generated control name (for id/for wiring).
requiredinput<boolean>()field → controlMetadata from a required validator.
min / maxinput<number>()field → controlMetadata from min/max validators.
minLength / maxLengthinput<number>()field → controlMetadata from length validators.
patterninput<readonly RegExp[]>()field → controlMetadata from pattern validators.

💡 The mental shift: with CVA you pushed and pulled values through callbacks. With FormValueControl you 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/dirty models 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

AspectControlValueAccessorFormValueControl
Package@angular/forms@angular/forms/signals
Required boilerplate4 methods + forwardRef + provider1 value model signal
forwardRef / NG_VALUE_ACCESSOR✅ Required❌ Not needed
Value syncManual callbacksAutomatic via model()
Disabled statesetDisabledState() methoddisabled = input()
Touched stateregisterOnTouched callbacktouched = model()
Validation errors in controlNot provided directlyerrors = input()
Validator metadata (required, min…)Not availableOptional input signals
Binding in templateformControlName / ngModel[control] directive
Change detectionZone-basedSignal-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/dirty as models — no callback registration.
  • Signal-native & zoneless-friendly — integrates with the rest of the signals ecosystem and fine-grained reactivity.
  • Type-safeFormValueControl<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 touched yourself (e.g. on blur or 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 of value.
  • Keep accessibility in mind — wire name(), required(), and disabled() 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. ⚡