Angular ControlValueAccessor: Building Custom Form Controls for Reactive Forms
π ControlValueAccessor (CVA) β The Bridge Between Forms and Custom Inputsβ
ControlValueAccessor (CVA) is the interface Angular uses to connect a form control (FormControl, ngModel) to a DOM element. It is the translator that sits between Angular's Forms API and your component's view.
Every native form element you bind with formControlName β <input>, <select>, <textarea> β already works because Angular ships built-in CVAs for them. The moment you want your own component (a rating stars widget, a custom toggle, a color picker) to behave like a first-class form control, you must implement ControlValueAccessor yourself.
π€ Why Is CVA Needed?β
Angular's Forms API (FormControl) is completely decoupled from the DOM. A FormControl only knows about a value and a status β it has no idea whether that value should be shown in an <input>, a slider, or a set of stars.
So there's a gap:
ControlValueAccessor is the contract that fills that gap. It defines a two-way communication channel:
| Direction | Method | Purpose |
|---|---|---|
| Model β View | writeValue(value) | Angular pushes a new value into your component (e.g. on patchValue, form reset, initial load). |
| View β Model | registerOnChange(fn) | Your component calls fn(newValue) to push user changes back to the FormControl. |
| View β Model | registerOnTouched(fn) | Your component calls fn() when the user "touches"/blurs the control (drives touched/untouched). |
| Model β View | setDisabledState(isDisabled) | Angular tells your component to enable/disable itself (e.g. control.disable()). |
Without this interface, Angular literally has no way to read from or write to your custom component.
β Why You Can't Do It Without CVAβ
Suppose you build a custom rating component and try to use it in a reactive form:
<!-- β This does NOT work -->
<form [formGroup]="form">
<app-star-rating formControlName="rating"></app-star-rating>
</form>
Angular will throw a runtime error:
Error: No value accessor for form control with name: 'rating'
Why it failsβ
formControlNametells Angular: "bind thisFormControlto this element."- To do that, Angular looks for a value accessor registered on that element via the
NG_VALUE_ACCESSORtoken. - Native elements (
<input>) have one built in. Your<app-star-rating>does not. - With no accessor, Angular can't call
writeValue()(to display the value) or receive changes β so it refuses to bind and throws.
What about "just using @Input/@Output"?β
You could expose @Input() value and @Output() valueChange and use banana-in-a-box [(value)] binding. But then your component is not a real form control:
- π΄ It won't work with
formControlName/formControl/ngModel. - π΄ It's excluded from validation β no
required, no custom validators, noform.valid. - π΄ It has no
touched/dirty/pristinestate, so error styling and messages don't integrate. - π΄ It won't respond to
form.reset(),patchValue(),disable()automatically. - π΄ You must manually wire value sync in every parent form.
CVA is what makes your component a genuine, reusable form control that participates in the entire Forms ecosystem. That's why it can't be skipped.
π οΈ Building a Custom Input with CVA β Step by Stepβ
Let's build a custom text input first (simplest case), then a richer star-rating control.
The Four Methods to Implementβ
interface ControlValueAccessor {
writeValue(value: any): void; // model β view
registerOnChange(fn: (value: any) => void): void; // save the "notify Angular" callback
registerOnTouched(fn: () => void): void; // save the "mark touched" callback
setDisabledState?(isDisabled: boolean): void; // optional: handle disabling
}
Step 1 β Register the component as a value accessorβ
You must provide your component under the NG_VALUE_ACCESSOR token so Angular can discover it. Use forwardRef because the class is referenced before it's defined.
import { Component, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'app-custom-input',
standalone: true,
template: `
<input
[value]="value"
[disabled]="disabled"
(input)="onInput($event)"
(blur)="onTouched()"
/>
`,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CustomInputComponent),
multi: true, // NG_VALUE_ACCESSOR is a multi-provider token
},
],
})
export class CustomInputComponent implements ControlValueAccessor {
value = '';
disabled = false;
// Callbacks Angular hands us; we call them to talk back to the FormControl
private onChange: (value: string) => void = () => {};
onTouched: () => void = () => {};
// 1οΈβ£ MODEL β VIEW: Angular calls this to set our value
writeValue(value: string): void {
this.value = value ?? '';
}
// 2οΈβ£ Save the callback that pushes changes back to the FormControl
registerOnChange(fn: (value: string) => void): void {
this.onChange = fn;
}
// 3οΈβ£ Save the callback that marks the control as touched
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
// 4οΈβ£ Angular calls this on control.disable()/enable()
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
// VIEW β MODEL: user typed β notify Angular
onInput(event: Event): void {
this.value = (event.target as HTMLInputElement).value;
this.onChange(this.value); // pushes the new value into the FormControl
}
}
π What is forwardRef and why is it needed here?β
forwardRef lets you refer to a class before it has been defined. It solves a subtle JavaScript timing problem.
Unlike functions, a class declaration is not hoisted. Between the top of the module and the line where the class is declared, the name exists but sits in the "temporal dead zone" (TDZ) β using it throws ReferenceError: Cannot access 'X' before initialization.
Now look at the order of execution in the component above:
@Component({
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CustomInputComponent), // β¬
οΈ referenced HERE
multi: true,
},
],
})
export class CustomInputComponent { /* ... */ } // β¬
οΈ defined LATER
The @Component decorator (including its providers array) is evaluated while the class is still being set up β that is, before the CustomInputComponent binding is initialized. So at that instant the class name is in the TDZ and referencing it directly would crash:
// β Eager β evaluated immediately β class not defined yet β ReferenceError
useExisting: CustomInputComponent
// β
Lazy β the () => ... isn't run until Angular resolves DI, long after the class exists
useExisting: forwardRef(() => CustomInputComponent)
What forwardRef actually does:
- Takes an arrow function
() => CustomInputComponentand stores it without calling it. - Returns a special marker that Angular's dependency injection understands.
- Later, once the module has finished loading and the class is fully defined, Angular invokes the function to get the real reference.
In other words, it's lazy evaluation: "Don't look up the class now β call this function to get it when you actually need it." By resolution time, the class exists.
Why the CVA pattern specifically triggers this: a component that registers itself under NG_VALUE_ACCESSOR is inherently circular β the class's own metadata needs a reference to the class before the class finishes initializing. forwardRef breaks that chicken-and-egg cycle.
When you need it (and when you don't):
| Situation | Need forwardRef? |
|---|---|
A component provides itself in its own providers (CVA, self-validator) | β Yes |
| Two classes inject each other (circular DI) | β Yes |
| Injecting a class/token declared later in the same file | β Yes |
| Referencing a class already defined above the usage | β No |
| Referencing something imported from another module | β No |
π§ Mental model:
forwardRef(() => X)means "give Angular a function it can call later to getX, instead of the value ofXright now." Needed wheneverXisn't defined yet at the point of reference.
Step 2 β Use it like any native controlβ
@Component({
selector: 'app-form',
standalone: true,
imports: [ReactiveFormsModule, CustomInputComponent],
template: `
<form [formGroup]="form">
<!-- β
Works exactly like a native input now -->
<app-custom-input formControlName="username"></app-custom-input>
<p>Value: {{ form.value.username }}</p>
<p>Touched: {{ form.controls.username.touched }}</p>
<p>Valid: {{ form.controls.username.valid }}</p>
<button (click)="form.controls.username.disable()">Disable</button>
<button (click)="form.reset()">Reset</button>
</form>
`,
})
export class FormComponent {
form = new FormGroup({
username: new FormControl('', Validators.required),
});
}
Notice everything just works: required validation, touched state, disable(), and reset() β because your component now honors the CVA contract.
β A Real Example: Custom Star-Rating Controlβ
This shows CVA shining where a native input can't help at all.
import { Component, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'app-star-rating',
standalone: true,
template: `
<div class="stars" [class.disabled]="disabled">
@for (star of stars; track star) {
<span
class="star"
[class.filled]="star <= value"
(click)="rate(star)"
(mouseenter)="hover = star"
(mouseleave)="hover = 0"
>
{{ (hover || value) >= star ? 'β
' : 'β' }}
</span>
}
</div>
`,
styles: [`
.star { cursor: pointer; font-size: 1.8rem; color: #f5a623; }
.disabled .star { cursor: not-allowed; opacity: 0.5; }
`],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => StarRatingComponent),
multi: true,
},
],
})
export class StarRatingComponent implements ControlValueAccessor {
readonly stars = [1, 2, 3, 4, 5];
value = 0;
hover = 0;
disabled = false;
private onChange: (value: number) => void = () => {};
private onTouched: () => void = () => {};
writeValue(value: number): void {
this.value = value ?? 0;
}
registerOnChange(fn: (value: number) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
rate(star: number): void {
if (this.disabled) return;
this.value = star;
this.onChange(star); // view β model
this.onTouched(); // mark as touched
}
}
Usage:
<form [formGroup]="reviewForm">
<app-star-rating formControlName="rating"></app-star-rating>
</form>
reviewForm = new FormGroup({
rating: new FormControl(0, [Validators.required, Validators.min(1)]),
});
Now the rating widget supports validation (min(1)), touched state for showing "please rate" errors, patchValue({ rating: 4 }), and reset() β all for free.
π The Data Flow, Visualizedβ
π§© Bonus: Less Boilerplate with a Directive Host or Signalsβ
Avoiding forwardRef with a provider factoryβ
The forwardRef + provider block is repetitive. You can extract it into a reusable helper:
import { Provider, Type, forwardRef } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
export function provideValueAccessor(component: Type<any>): Provider {
return {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => component),
multi: true,
};
}
// Usage:
// providers: [provideValueAccessor(StarRatingComponent)]
Adding validation from the same componentβ
Implement Validator and register NG_VALIDATORS alongside NG_VALUE_ACCESSOR if your control should carry its own validation logic:
import { NG_VALIDATORS, Validator, AbstractControl, ValidationErrors } from '@angular/forms';
providers: [
provideValueAccessor(StarRatingComponent),
{ provide: NG_VALIDATORS, useExisting: forwardRef(() => StarRatingComponent), multi: true },
]
// in the class
validate(control: AbstractControl): ValidationErrors | null {
return control.value >= 1 ? null : { required: true };
}
β Best Practices & Gotchasβ
- Always
multi: trueon theNG_VALUE_ACCESSORprovider β it's a multi-provider token; forgetting it breaks binding. - Handle
null/undefinedinwriteValueβ forms passnullonreset(). Guard with?? defaultValue. - Call
onTouched()on blur/interaction end β otherwisetouchednever becomestrueand error messages won't show. - Respect
setDisabledStateβ don't let users interact when the control is disabled. - Don't call
onChangeinsidewriteValueβ that would create a loop (modelβviewβmodel).writeValueshould only update the display. - Prefer
changeDetection: OnPushfor performance; callmarkForCheck()if you mutate state outside Angular events. - Works with template-driven forms too β the same CVA makes
[(ngModel)]work on your component.
π― Summaryβ
ControlValueAccessor is the bridge that lets a custom component behave like a native form control. Angular's FormControl is DOM-agnostic, so it needs a translator: writeValue pushes values model β view, while registerOnChange/registerOnTouched push changes and touch state view β model, and setDisabledState syncs the disabled state. You can't integrate a custom widget with formControlName, validation, or form state without it β that's exactly why Angular throws "No value accessor" when it's missing. Implement the four methods, register under NG_VALUE_ACCESSOR (with multi: true), and your component becomes a full citizen of Angular's Forms API. β