Custom Form Controls (ControlValueAccessor)
Angular’s form system only natively understands a handful of native HTML elements: <input>, <select>, and <textarea>. The moment you build your own widget — a star rating, a color swatch picker, a toggle switch — Angular has no idea how to read or write its value. ControlValueAccessor (CVA) is the bridge: it is a small interface that teaches Angular how to talk to your component, so it works seamlessly with ngModel, formControl, and formControlName just like a built-in input.
Why ControlValueAccessor exists
Every form-bound element needs to do four things: push a value into the view when the model changes, push a value out when the user interacts, tell Angular when the control was “touched” (blurred), and react to being disabled. Native inputs do this through the DOM. Your custom component has no such contract — until you implement ControlValueAccessor, which standardises exactly those four operations.
The interface looks like this:
interface ControlValueAccessor {
writeValue(value: any): void;
registerOnChange(fn: (value: any) => void): void;
registerOnTouched(fn: () => void): void;
setDisabledState?(isDisabled: boolean): void;
}
| Method | Direction | Purpose |
|---|---|---|
writeValue | model → view | Angular calls this to set the value displayed by your control |
registerOnChange | view → model | Angular hands you a callback; call it when the value changes |
registerOnTouched | view → model | Angular hands you a callback; call it on blur |
setDisabledState | model → view | Angular calls this when the control is enabled/disabled |
Implementing a custom star-rating control
Let’s build a standalone star-rating component that participates in any form. The key wiring is the NG_VALUE_ACCESSOR provider, which registers the component as a value accessor with the forms module.
import { Component, forwardRef, signal } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'app-star-rating',
standalone: true,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => StarRatingComponent),
multi: true,
},
],
template: `
<div class="stars" role="radiogroup">
@for (star of stars; track star) {
<button
type="button"
[class.filled]="star <= value()"
[disabled]="disabled()"
(click)="select(star)"
(blur)="onTouched()"
aria-label="Rate {{ star }}"
>★</button>
}
</div>
`,
styles: `
button { font-size: 1.5rem; color: #ccc; background: none; border: 0; cursor: pointer; }
button.filled { color: gold; }
button:disabled { cursor: not-allowed; opacity: 0.5; }
`,
})
export class StarRatingComponent implements ControlValueAccessor {
readonly stars = [1, 2, 3, 4, 5];
readonly value = signal(0);
readonly disabled = signal(false);
// Placeholders replaced by Angular via the register* hooks.
private onChange: (value: number) => void = () => {};
onTouched: () => void = () => {};
writeValue(value: number): void {
this.value.set(value ?? 0);
}
registerOnChange(fn: (value: number) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled.set(isDisabled);
}
select(star: number): void {
if (this.disabled()) return;
this.value.set(star);
this.onChange(star); // notify the form model
this.onTouched(); // mark as touched
}
}
The
forwardRefis mandatory here: the provider referencesStarRatingComponentbefore its class declaration is fully evaluated. WithoutforwardRef, you get aCannot access 'StarRatingComponent' before initializationerror at runtime.
Using it in a reactive form
Because the component registered itself as a value accessor, it now behaves exactly like a native input. Bind it with formControlName and it just works — including validation state.
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { StarRatingComponent } from './star-rating.component';
@Component({
selector: 'app-review-form',
standalone: true,
imports: [ReactiveFormsModule, StarRatingComponent],
template: `
<form [formGroup]="form" (ngSubmit)="submit()">
<app-star-rating formControlName="rating" />
@if (form.controls.rating.touched && form.controls.rating.invalid) {
<p class="error">Please choose at least one star.</p>
}
<button type="submit" [disabled]="form.invalid">Submit review</button>
</form>
`,
})
export class ReviewFormComponent {
private fb = inject(FormBuilder);
form = this.fb.group({
rating: [0, [Validators.min(1)]],
});
submit() {
console.log('Submitted:', this.form.getRawValue());
}
}
Selecting three stars and submitting prints:
Output:
Submitted: { rating: 3 }
The same component works with template-driven forms via [(ngModel)] with zero changes — that is the whole point of the abstraction.
<app-star-rating [(ngModel)]="movieRating" name="movieRating" />
Adding validation to the control itself
Sometimes the control should own its validation rules rather than relying on the consuming form. Provide NG_VALIDATORS and implement Validator:
import { NG_VALIDATORS, Validator, AbstractControl, ValidationErrors } from '@angular/forms';
// add to the component's providers array:
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => StarRatingComponent),
multi: true,
}
// implement the interface method:
validate(control: AbstractControl): ValidationErrors | null {
return control.value > 0 ? null : { required: true };
}
Now every form that uses <app-star-rating> automatically inherits the “must pick a star” rule without the parent declaring Validators.min.
Best Practices
- Always include
multi: trueon theNG_VALUE_ACCESSORprovider — value accessors are a multi-provider token and omitting it overwrites Angular’s built-ins. - Use
forwardRefto avoid initialization-order errors when referencing the component class inside its ownproviders. - Call the registered
onChangecallback only on real user-driven changes, and callonTouchedon blur — never insidewriteValue, which Angular calls programmatically. - Implement
setDisabledStateso reactive forms can disable your control viacontrol.disable(); disabled controls should ignore user input. - Prefer signals for internal state so the template reacts automatically without manual change detection.
- Co-locate control-level validation with
NG_VALIDATORSwhen the rule is intrinsic to the widget, and leave context-specific rules to the parent form.