Skip to content
Angular ng forms 4 min read

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;
}
MethodDirectionPurpose
writeValuemodel → viewAngular calls this to set the value displayed by your control
registerOnChangeview → modelAngular hands you a callback; call it when the value changes
registerOnTouchedview → modelAngular hands you a callback; call it on blur
setDisabledStatemodel → viewAngular 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 forwardRef is mandatory here: the provider references StarRatingComponent before its class declaration is fully evaluated. Without forwardRef, you get a Cannot access 'StarRatingComponent' before initialization error 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: true on the NG_VALUE_ACCESSOR provider — value accessors are a multi-provider token and omitting it overwrites Angular’s built-ins.
  • Use forwardRef to avoid initialization-order errors when referencing the component class inside its own providers.
  • Call the registered onChange callback only on real user-driven changes, and call onTouched on blur — never inside writeValue, which Angular calls programmatically.
  • Implement setDisabledState so reactive forms can disable your control via control.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_VALIDATORS when the rule is intrinsic to the widget, and leave context-specific rules to the parent form.
Last updated June 14, 2026
Was this helpful?