skip to content

In Angular, what does a ControlValueAccessor do, and what is each of its four methods for in a custom star-rating control?

level: middleimportance: must knowfreq 62%

answer

  1. a bridge in both directions
  2. model to view, view to model
  3. store the callbacks, call them later
  4. touched comes from a separate callback
  5. setDisabledState is optional

basics

~20 s

A ControlValueAccessor bridges a FormControl and a UI element: writeValue pushes the model value into the view, registerOnChange and registerOnTouched hand over callbacks the component calls on user edits and blur, and optional setDisabledState mirrors the disabled status.

solid answer

~30 s

`ControlValueAccessor` is the interface from `@angular/forms` that lets any component act as a form control under `formControlName`, `[formControl]` or `ngModel`. The forms API calls `writeValue(value)` whenever the model changes programmatically, so the component renders it. It calls `registerOnChange(fn)` and `registerOnTouched(fn)` once at setup; the component stores both functions and calls `onChange(newValue)` when the **user** picks a star, and `onTouched()` when the user leaves the control. The optional `setDisabledState(isDisabled)` is called on attach and whenever the control is disabled or enabled. The component registers itself with `{provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => StarRating), multi: true}`.

code

ts · 45 lines
ts
import {Component, forwardRef, signal} from '@angular/core';
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';

@Component({
  selector: 'app-star-rating',
  template: `
    @for (star of stars; track star) {
      <button type="button"
              [class.filled]="star <= value()"
              [attr.aria-label]="star + ' stars'"
              [disabled]="disabled()"
              (click)="rate(star)"
              (blur)="onTouched()">{{ star }}</button>
    }
  `,
  providers: [
    {provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => StarRating), multi: true},
  ],
})
export class StarRating 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 = () => {};
  protected onTouched: () => void = () => {};

  // model -> view: called by the forms API, may receive null (reset, ngModel's first pass)
  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);
  }
  // view -> model: only user actions call onChange
  protected rate(star: number): void {
    this.value.set(star);
    this.onChange(star);
  }
}

go deeper

for a junior

Name the four methods and say which direction each one carries data. Being able to sketch the provider with NG_VALUE_ACCESSOR, useExisting and multi: true is the usual follow-up.

for a middle

Explain when each method is called: writeValue at attach and on every programmatic change, the two register methods once, setDisabledState on attach and on disable or enable. Mention that writeValue can receive null.

for a senior

Show you have debugged real accessors: onChange never called from writeValue, onTouched wired to the element the user actually leaves, and the displayed value held in a signal so an OnPush view refreshes.

for a principal

Discuss when a shared component library should expose CVAs versus Signal Forms controls, and how to keep one widget usable from reactive, template-driven and signal forms during a migration.

## What problem a ControlValueAccessor solves Angular's forms packages keep the form's state in **model objects**: `FormControl`, `FormGroup` and `FormArray`. Directives such as `formControlName`, `[formControl]` and `ngModel` connect one of those models to one element in the template. For a native `<input>`, Angular already ships the glue, a set of built-in accessors such as `DefaultValueAccessor`, `CheckboxControlValueAccessor` and `SelectControlValueAccessor`. For a component of your own, such as a five-star rating widget, Angular has no idea which property holds the value or which event means "the user changed it". A **`ControlValueAccessor`** (CVA) is the interface that answers those questions. Once a component implements it and registers itself, it works with every forms directive exactly like a native input. The interface is the contract between two parties: - the **forms API**, which owns the value, validity, `touched`, `dirty` and disabled state; - the **component**, which owns the pixels and the user interaction. ## The four methods, by direction | Method | Who calls it | Direction | What the component does | |---|---|---|---| | `writeValue(obj)` | the forms API | model to view | render the value it was given | | `registerOnChange(fn)` | the forms API, once at setup | view to model | store `fn`, call it on every user edit | | `registerOnTouched(fn)` | the forms API, once at setup | view to model | store `fn`, call it when the user leaves the control | | `setDisabledState?(isDisabled)` | the forms API | model to view | disable or enable its own interaction | Key points about each: 1. **`writeValue`** runs when the directive attaches (with the control's current value) and every time the model changes through `setValue`, `patchValue` or `reset` (unless the caller passes `emitModelToViewChange: false`). It can receive `null`, for example after `reset()` on a nullable control, or on the first pass of `ngModel`, which sets its value a microtask later. It must only update the display. 2. **`registerOnChange`** gives the component the function that feeds the model. The component calls `onChange(newValue)` from its own user event handlers, never from `writeValue`. 3. **`registerOnTouched`** gives a separate function for the **touched** flag. Calling it is how the control becomes `touched`, which is what most error messages wait for. 4. **`setDisabledState`** is marked optional in the interface. Since Angular 15 the forms API calls it as soon as the accessor is attached (with `false` for an enabled control) and again on each `disable()` or `enable()`. ## Registering the component A CVA is found through the multi-provider token `NG_VALUE_ACCESSOR`. The component adds this to its own `providers`: ```ts providers: [ {provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => StarRating), multi: true}, ] ``` `useExisting` points the token at the very component instance that renders, `forwardRef` is needed because the class is referenced inside its own decorator, and `multi: true` is required because the directive reads the token as an array of candidate accessors. ## Using it from both form styles The same component then works in reactive and template-driven forms without changes: ```html <app-star-rating formControlName="rating" /> <app-star-rating [(ngModel)]="score" name="score" /> ``` ## A good mental model - **Two pipes, two directions.** `writeValue` and `setDisabledState` flow in; the two stored callbacks flow out. - **The component does not own the value.** It mirrors whatever the model says and reports user intent; the `FormControl` decides what the value is. - **Callbacks are stored, not called at setup.** `registerOnChange` and `registerOnTouched` hand over functions; nothing happens until the component calls them. ## Common mistakes the interface does not prevent TypeScript only checks the method signatures, so several real bugs compile cleanly: - **Calling `onChange` from `writeValue`.** A programmatic write is then reported as a user edit, which marks the control dirty and emits `valueChanges` a second time. - **Never calling `onTouched`.** The control never becomes touched, so errors that wait on `touched` never appear. - **Skipping `setDisabledState`.** Because the method is optional, `control.disable()` silently does nothing visible. - **Assuming a non-null value.** `writeValue(null)` arrives after `reset()` on a nullable control and on the first `ngModel` pass. In current Angular the idiomatic way to hold the displayed value is a `signal()`: components are `OnPush` by default since v22, and writing a signal that the template reads schedules that view to refresh, while writing a plain field from `writeValue` does not.

  • Why must writeValue not call the stored onChange callback?
    `writeValue` reports a programmatic model change. Calling `onChange` from it sends that value back as if the user had edited it: the forms API marks the control dirty and emits `valueChanges` a second time, and with `ngModel` it also fires `ngModelChange`. It does not loop forever, because view-originated updates skip writing back to the view, but the state is wrong.
  • What value should writeValue expect on the very first call?
    With `formControlName` it receives whatever the `FormControl` holds at attach time, which is `null` for a control created without a value. With `ngModel` it first receives `null`, because the underlying control is set up before `ngModel` writes its bound value one microtask later. A robust `writeValue` treats `null` as the empty state instead of crashing.

saying these in an interview costs you the question

  • writeValue is only called once, when the control is created
  • The component should call onChange inside writeValue to keep the form in sync
  • Angular marks a custom control touched automatically when the host element blurs
  • ControlValueAccessor is only for reactive forms, not ngModel
  • The component owns the value and the FormControl just reads it