Angular Custom Form Controls with ControlValueAccessor

Angular Tutorial: Beginner to Advanced · 16. Advanced forms, state and RxJS · 4 min read · Updated

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 ControlValueAccessor does
  • 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_ACCESSOR is what makes formControlName and ngModel accept your component.
  • Call onChange when the user changes the value, and onTouched when they leave the field. Do not call them from writeValue.
  • writeValue must also handle null, which happens after reset() on a control without a value.
  • setDisabledState lets control.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

  1. Add arrow-key support so the left and right keys change the rating.
  2. Add a half-star mode.
  3. Write a control for a list of tags that edits a string[] value.

Quick check

  1. Which token do you provide to register a custom control?
  2. When should onChange be called?
  3. Where should validation rules live?

Next, build forms that grow and shrink with FormArray and dynamic forms. See the forms guide.

Frequently asked questions

Is there a signal-based alternative?

Angular is developing a signal-based forms API. Check the current documentation before adopting it, since it is newer than the reactive forms taught here.

Do I need ControlValueAccessor for every custom input?

Only if the component should plug into Angular forms. For a standalone widget, plain inputs and outputs are simpler.