Ch. 4 · Angular

Angular Custom Controls and Value Accessors

A star-rating ControlValueAccessor, built and then broken: what writeValue, onChange, onTouched and setDisabledState each must do.

~5 min readintermediateupdated Oct 7, 2026

Angular’s form directives (formControlName, [formControl], ngModel) don’t talk to elements directly. They talk to a ControlValueAccessor, an adapter that knows how to put a value into a particular piece of UI and how to report changes back. Angular ships accessors for inputs, checkboxes, selects and radios. When you build your own control, a star rating, a date range picker, a tag input, you write the accessor yourself, and then it works with reactive forms, template-driven forms, validators and form.value like any native input.

The interface has four methods, and each one has a job that’s easy to get slightly wrong. This note builds a star rating, then deliberately breaks it in the common ways. The logs come from Angular 20.3 under Node with TestBed and a real FormGroup; Angular 19 behaves the same.

The contract in one paragraph

Think of two directions. Model to view: the form control’s value changes (initial value, setValue, patchValue, reset) and Angular calls your writeValue(value) so the UI can show it. View to model: the user does something in your UI, and you call the onChange(value) function Angular gave you through registerOnChange, so the form control updates. onTouched, registered through registerOnTouched, reports “the user has interacted and left”, which drives touched and updateOn: 'blur'. setDisabledState(isDisabled) tells you when the control is disabled or enabled.

A working star rating

@Component({
  selector: 'app-rating',
  providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => Rating), multi: true }],
  template: `
    @for (n of [1, 2, 3, 4, 5]; track n) {
      <button type="button" [disabled]="disabled()" (click)="pick(n)" (blur)="onTouched()">
        {{ n <= value() ? '★' : '☆' }}
      </button>
    }
  `,
})
export class Rating implements ControlValueAccessor {
  value = signal(0);
  disabled = signal(false);
  private onChange: (v: number) => void = () => {};
  onTouched: () => void = () => {};

  writeValue(v: number | null) { this.value.set(v ?? 0); }
  registerOnChange(fn: (v: number) => void) { this.onChange = fn; }
  registerOnTouched(fn: () => void) { this.onTouched = fn; }
  setDisabledState(d: boolean) { this.disabled.set(d); }

  pick(n: number) {
    this.value.set(n);
    this.onChange(n);
  }
}
TypeScript

Two pieces of wiring matter. The NG_VALUE_ACCESSOR provider with multi: true is how formControlName finds the accessor on the element. forwardRef is needed because the class is referenced inside its own decorator, before it’s defined. Forgetting the provider produces:

NG01203: No value accessor for form control unspecified name attribute.
Text

(with formControlName="rating" the message names the control).

Use it like any input: <app-rating formControlName="rating" />.

What Angular calls, and when

Attaching the control to a FormControl(3) logged this:

[good] writeValue(3)
[good] setDisabledState(false)
[good] registerOnChange
Text

writeValue runs first with the initial value, before registerOnChange. That ordering is why the default onChange = () => {} matters: a control that calls onChange too early would otherwise crash.

setDisabledState(false) is called on init even though the control isn’t disabled. Since Angular 15 the forms module calls it always (callSetDisabledState: 'always' is the default), so your implementation must handle false correctly and not assume it’s only called when something changes.

Note also that writeValue can receive null: form.reset() on a nullable control sends null. Handle it, as the ?? 0 does above.

Bug one: echoing in writeValue

A tempting “keep everything in sync” change:

writeValue(v: number | null) {
  this.value.set(v ?? 0);
  this.onChange(v ?? 0); // don't
}
TypeScript

The test ran a correct control and this “echo” version side by side and patched both programmatically:

form.patchValue({ good: 5, echo: 5 })
after programmatic patch: good: dirty=false | echo: dirty=true
Text

The echo version reported the programmatic value back as if the user had typed it, so the form became dirty without any user action. In a real app that means the “unsaved changes” prompt appears on a form the user never touched, and valueChanges fires twice per update, which can double up autosaves. writeValue is model to view only. Call onChange only in response to user input.

Bug two: never calling onTouched

The third control in the test never called onTouched, and its FormControl used updateOn: 'blur'. The user picked 1:

notouch: value=3 dirty=false touched=false
Text

The value stayed 3. With updateOn: 'blur', Angular holds the pending value until the control reports a blur through onTouched, and that never happened. Even with the default updateOn: 'change', touched would stay false, so error messages gated on control.touched (the usual “don’t shout until they’ve left the field” pattern) never appear, and markAllAsTouched() on submit is the only way the user sees them.

Call onTouched when focus leaves the control as a whole. In a composite control with several buttons, that means on blur of the last focusable part, or on focusout of the host when the new focus target is outside it.

Bug three: ignoring setDisabledState

form.get('rating').disable() called setDisabledState(true). The correct control disabled its buttons and ignored clicks, so the value didn’t change. A control that doesn’t implement setDisabledState (it’s optional in the interface) keeps accepting input, and calls onChange on a disabled FormControl, which updates its value even though disabled controls are excluded from form.value. Users see a field they can edit and a submission that ignores it.

Also avoid binding [disabled] in the template on an element that has a form directive; Angular warns about it in dev mode. Disable through the FormControl and let setDisabledState reflect it.

Validation belongs alongside, not inside

If the control has built-in rules (“a rating is required to be at least 1”), provide NG_VALIDATORS alongside the accessor and implement Validator.validate(control). Don’t call setErrors from inside the control; the next validation run overwrites it. Most of the time, though, the rule belongs to the form, as Validators.min(1) on the FormControl, so different forms can use the same control with different rules. The async validation note covers validators that call a server.

Interview practice

A team’s custom date-picker works in a reactive form, but they report: the edit page shows “You have unsaved changes” as soon as it loads, required-field errors never show until the user clicks Submit, and the date stays editable when the form is disabled during saving. What’s likely wrong in the ControlValueAccessor?

Answer

Each symptom maps to one method of the contract.

“Unsaved changes on load” means the form becomes dirty without user input. When the page loads data with patchValue or reset, Angular calls writeValue. If writeValue calls the registered onChange (often indirectly, for example by setting an internal value that triggers a change handler or an output that’s wired to onChange), the form treats it as user input and marks the control dirty. The fix is to keep writeValue strictly model to view and call onChange only from user events.

“Errors never show until Submit” points to onTouched never being called. The template probably shows errors when control.touched && control.invalid, and touched only flips when the accessor reports it. markAllAsTouched() on submit is why they appear then. I’d call onTouched when focus leaves the picker as a whole, including when the calendar popup closes.

“Still editable when disabled” means setDisabledState is missing or doesn’t disable the inner input and popup trigger. I’d implement it to set a disabled signal that the template binds to, and guard user handlers so they don’t call onChange while disabled. I’d add a component test that disables the FormControl and asserts the inner input is disabled, since this regresses easily.

Further reading

More in Angular

read ✓Angular · mid

Angular Template-Driven vs Reactive Forms

Both form styles build the same FormControl objects. What differs is who creates them and when, which changes timing, typing and testing.

~5 min readread →
read ✓Angular · hard

Angular Signal Forms Overview

How Signal Forms wrap your model signal in a FieldTree, keep validation in one schema, and map server errors onto fields when you submit.

~6 min readread →
esc