Skip to main content

6 min read #angular #forms #controlvalueaccessor #signals #frontend

My star-rating widget is a real form control now

ControlValueAccessor is four methods and one provider, and it turns any component into something formControlName can bind to — validators, dirty state, disabling and all. The contract, and the three bugs everyone ships.

Every custom input widget reaches the same fork in the road. The team builds a star rating, or a tag picker, or a duration field. It works. Then someone tries to drop it into an existing reactive form — formControlName="rating" — and it does nothing, so they "solve" it with a value input, a valueChange output, and a subscription gluing the two to the form in the parent. Multiply by every form the widget appears in, and validators, dirty tracking and disabling all live somewhere else, reimplemented, badly.

ControlValueAccessor is the actual answer: the interface Angular's own <input> directives implement behind the scenes. Implement it, and your component plugs into formControlName, formControl and ngModel like it was born there.

The contract is four methods

@Component({
  selector: 'app-star-rating',
  providers: [{
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => StarRatingComponent),
    multi: true,
  }],
  template: '...',
})
export class StarRatingComponent implements ControlValueAccessor {
  readonly value = signal(0);
  readonly disabled = signal(false);

  private onChange: (v: number) => void = () => {};
  private 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); }

  rate(stars: number) {
    if (this.disabled()) return;
    this.value.set(stars);
    this.onChange(stars);
    this.onTouched();
  }
}

Read it as two directions of traffic. writeValue is the form talking to you: initial value, setValue, reset. The two registered callbacks are you talking to the form: onChange when the user changes the value, onTouched when they've interacted enough that blur-style validation should wake up. The NG_VALUE_ACCESSOR provider — multi: true, forwardRef because the class isn't defined yet at decorator evaluation — is how the formControlName directive sitting on your tag finds you. Internal state as a signal is my one modernization: the template gets clean reactive reads, and nothing about the CVA contract objects.

The three bugs everyone ships

Silent touched state. Forgetting onTouched is invisible in the demo and obvious in production: any error message gated on control.touched — which is the convention almost every form uses — never appears for your widget. Call it on blur, or on first interaction if your control has no meaningful blur.

Ignoring setDisabledState. form.disable() reaches your component exclusively through this method. Skip it and your widget stays merrily clickable inside a disabled form — worse than a compile error because nobody tests disabling until a submit button starts double-firing.

The echo loop. writeValue must update internal state silently. If it calls onChange — directly, or indirectly because your setter fires the same path user interaction does — then every programmatic setValue bounces back into the form model. Best case it re-runs validators and marks the form dirty from a non-user change; worst case, with value normalization in the middle, it ping-pongs forever. Keep the paths separate: writeValue writes the signal; rate() writes the signal and reports.

When CVA is the wrong tool

The ceremony earns its keep when the component owns a genuinely non-input interaction model — stars, chips, a canvas signature pad. If your "custom control" is a styled wrapper around native inputs (a labeled input with an icon, an address group), you don't need a value accessor; pass the FormControl in as an input, or reach for ControlContainer injection so child and parent share the form group. I've reviewed CVAs wrapping a single <input> that re-forwarded every event by hand — forty lines that replaced zero.

But when it fits, it's one of Angular's best contracts: the day our star rating became a real control, three components deleted their glue subscriptions, validation messages started appearing on blur like everywhere else, and the widget stopped being special. That's the goal — not a clever component, an unremarkable one.