Built-in inputs are not enough for every form. A star rating, a tag picker or a date range need their own UI but should still behave like a normal field: work with formControlName, validation, disabled and reset(). The bridge is the ControlValueAccessor interface.
What you will learn
- What
ControlValueAccessordoes - How to implement the four methods
- How to use the control inside a reactive form with validation
The idea
Angular forms talk to your component through four methods:
writeValue(value): the form gives your component a new value.registerOnChange(fn): you receive a function to call when the user changes the value.registerOnTouched(fn): you receive a function to call when the field is touched.setDisabledState(isDisabled): the form asks you to enable or disable the UI.
A star rating control
// src/app/forms/star-rating-control.ts
import { Component, forwardRef, signal } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'app-star-rating-control',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => StarRatingControl),
multi: true,
},
],
template: `
<div role="radiogroup" aria-label="Rating" (focusout)="markTouched()">
@for (star of stars; track star) {
<button
type="button"
role="radio"
[attr.aria-checked]="value() === star"
[attr.aria-label]="star + (star === 1 ? ' star' : ' stars')"
[disabled]="disabled()"
(click)="select(star)"
>
{{ star <= value() ? '★' : '☆' }}
</button>
}
</div>
`,
styles: `button { font-size: 1.5rem; background: none; border: 0; cursor: pointer; } button:disabled { opacity: .5; cursor: default; }`,
})
export class StarRatingControl implements ControlValueAccessor {
protected readonly stars = [1, 2, 3, 4, 5];
protected readonly value = signal(0);
protected readonly disabled = signal(false);
private onChange: (value: number) => void = () => {};
private onTouched: () => void = () => {};
writeValue(value: number | null): 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);
}
protected select(star: number): void {
if (this.disabled()) {
return;
}
this.value.set(star);
this.onChange(star);
this.onTouched();
}
protected markTouched(): void {
this.onTouched();
}
}
forwardRef is needed because the class is referenced inside its own decorator, before it is defined.
Use it in a reactive form
The form uses JsonPipe to show the current value while you test:
// src/app/forms/review-form.ts
import { JsonPipe } from '@angular/common';
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { StarRatingControl } from './star-rating-control';
@Component({
selector: 'app-review-form',
imports: [ReactiveFormsModule, StarRatingControl, JsonPipe],
template: `
<form [formGroup]="form" (ngSubmit)="submit()" novalidate>
<p>Rate this course:</p>
<app-star-rating-control formControlName="rating" />
@if (form.controls.rating.touched && form.controls.rating.hasError('min')) {
<p role="alert">Please choose a rating.</p>
}
<p>
<label for="comment">Comment</label><br />
<textarea id="comment" formControlName="comment"></textarea>
</p>
<button type="submit">Send review</button>
<button type="button" (click)="form.reset({ rating: 0, comment: '' })">Reset</button>
<button type="button" (click)="toggleDisabled()">Toggle disabled</button>
</form>
<pre>{{ form.getRawValue() | json }}</pre>
`,
})
export class ReviewForm {
private readonly fb = inject(FormBuilder).nonNullable;
protected readonly form = this.fb.group({
rating: [0, Validators.min(1)],
comment: ['', [Validators.required, Validators.minLength(5)]],
});
protected submit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
console.log('Submitted', this.form.getRawValue());
}
protected toggleDisabled(): void {
const rating = this.form.controls.rating;
if (rating.disabled) {
rating.enable();
} else {
rating.disable();
}
}
}
// src/app/app.ts
import { Component } from '@angular/core';
import { ReviewForm } from './forms/review-form';
@Component({
imports: [ReviewForm],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {}
<!-- src/app/app.html -->
<main>
<h1>Custom control</h1>
<app-review-form />
</main>
The key parts
- Registering
NG_VALUE_ACCESSORis what makesformControlNameandngModelaccept your component. - Call
onChangewhen the user changes the value, andonTouchedwhen they leave the field. Do not call them fromwriteValue. writeValuemust also handlenull, which happens afterreset()on a control without a value.setDisabledStateletscontrol.disable()work for free.- Validators stay in the form, not in the control. The control only edits a value.
- Make it accessible: roles, labels and keyboard support (buttons are focusable by default).
Try it yourself
- Add arrow-key support so the left and right keys change the rating.
- Add a half-star mode.
- Write a control for a list of tags that edits a
string[]value.
Quick check
- Which token do you provide to register a custom control?
- When should
onChangebe called? - Where should validation rules live?
Next, build forms that grow and shrink with FormArray and dynamic forms. See the forms guide.