Skip to main content

Angular ControlValueAccessor: Building Custom Form Controls for Reactive Forms

πŸ”Œ ControlValueAccessor (CVA) β€” The Bridge Between Forms and Custom Inputs​

ControlValueAccessor (CVA) is the interface Angular uses to connect a form control (FormControl, ngModel) to a DOM element. It is the translator that sits between Angular's Forms API and your component's view.

Every native form element you bind with formControlName β€” <input>, <select>, <textarea> β€” already works because Angular ships built-in CVAs for them. The moment you want your own component (a rating stars widget, a custom toggle, a color picker) to behave like a first-class form control, you must implement ControlValueAccessor yourself.


πŸ€” Why Is CVA Needed?​

Angular's Forms API (FormControl) is completely decoupled from the DOM. A FormControl only knows about a value and a status β€” it has no idea whether that value should be shown in an <input>, a slider, or a set of stars.

So there's a gap:

ControlValueAccessor is the contract that fills that gap. It defines a two-way communication channel:

DirectionMethodPurpose
Model β†’ ViewwriteValue(value)Angular pushes a new value into your component (e.g. on patchValue, form reset, initial load).
View β†’ ModelregisterOnChange(fn)Your component calls fn(newValue) to push user changes back to the FormControl.
View β†’ ModelregisterOnTouched(fn)Your component calls fn() when the user "touches"/blurs the control (drives touched/untouched).
Model β†’ ViewsetDisabledState(isDisabled)Angular tells your component to enable/disable itself (e.g. control.disable()).

Without this interface, Angular literally has no way to read from or write to your custom component.


❌ Why You Can't Do It Without CVA​

Suppose you build a custom rating component and try to use it in a reactive form:

<!-- ❌ This does NOT work -->
<form [formGroup]="form">
<app-star-rating formControlName="rating"></app-star-rating>
</form>

Angular will throw a runtime error:

Error: No value accessor for form control with name: 'rating'

Why it fails​

  • formControlName tells Angular: "bind this FormControl to this element."
  • To do that, Angular looks for a value accessor registered on that element via the NG_VALUE_ACCESSOR token.
  • Native elements (<input>) have one built in. Your <app-star-rating> does not.
  • With no accessor, Angular can't call writeValue() (to display the value) or receive changes β€” so it refuses to bind and throws.

What about "just using @Input/@Output"?​

You could expose @Input() value and @Output() valueChange and use banana-in-a-box [(value)] binding. But then your component is not a real form control:

  • πŸ”΄ It won't work with formControlName / formControl / ngModel.
  • πŸ”΄ It's excluded from validation β€” no required, no custom validators, no form.valid.
  • πŸ”΄ It has no touched / dirty / pristine state, so error styling and messages don't integrate.
  • πŸ”΄ It won't respond to form.reset(), patchValue(), disable() automatically.
  • πŸ”΄ You must manually wire value sync in every parent form.

CVA is what makes your component a genuine, reusable form control that participates in the entire Forms ecosystem. That's why it can't be skipped.


πŸ› οΈ Building a Custom Input with CVA β€” Step by Step​

Let's build a custom text input first (simplest case), then a richer star-rating control.

The Four Methods to Implement​

interface ControlValueAccessor {
writeValue(value: any): void; // model β†’ view
registerOnChange(fn: (value: any) => void): void; // save the "notify Angular" callback
registerOnTouched(fn: () => void): void; // save the "mark touched" callback
setDisabledState?(isDisabled: boolean): void; // optional: handle disabling
}

Step 1 β€” Register the component as a value accessor​

You must provide your component under the NG_VALUE_ACCESSOR token so Angular can discover it. Use forwardRef because the class is referenced before it's defined.

import { Component, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
selector: 'app-custom-input',
standalone: true,
template: `
<input
[value]="value"
[disabled]="disabled"
(input)="onInput($event)"
(blur)="onTouched()"
/>
`,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CustomInputComponent),
multi: true, // NG_VALUE_ACCESSOR is a multi-provider token
},
],
})
export class CustomInputComponent implements ControlValueAccessor {
value = '';
disabled = false;

// Callbacks Angular hands us; we call them to talk back to the FormControl
private onChange: (value: string) => void = () => {};
onTouched: () => void = () => {};

// 1️⃣ MODEL β†’ VIEW: Angular calls this to set our value
writeValue(value: string): void {
this.value = value ?? '';
}

// 2️⃣ Save the callback that pushes changes back to the FormControl
registerOnChange(fn: (value: string) => void): void {
this.onChange = fn;
}

// 3️⃣ Save the callback that marks the control as touched
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}

// 4️⃣ Angular calls this on control.disable()/enable()
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}

// VIEW β†’ MODEL: user typed β†’ notify Angular
onInput(event: Event): void {
this.value = (event.target as HTMLInputElement).value;
this.onChange(this.value); // pushes the new value into the FormControl
}
}

πŸ”Ž What is forwardRef and why is it needed here?​

forwardRef lets you refer to a class before it has been defined. It solves a subtle JavaScript timing problem.

Unlike functions, a class declaration is not hoisted. Between the top of the module and the line where the class is declared, the name exists but sits in the "temporal dead zone" (TDZ) β€” using it throws ReferenceError: Cannot access 'X' before initialization.

Now look at the order of execution in the component above:

@Component({
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CustomInputComponent), // ⬅️ referenced HERE
multi: true,
},
],
})
export class CustomInputComponent { /* ... */ } // ⬅️ defined LATER

The @Component decorator (including its providers array) is evaluated while the class is still being set up β€” that is, before the CustomInputComponent binding is initialized. So at that instant the class name is in the TDZ and referencing it directly would crash:

// ❌ Eager β€” evaluated immediately β†’ class not defined yet β†’ ReferenceError
useExisting: CustomInputComponent

// βœ… Lazy β€” the () => ... isn't run until Angular resolves DI, long after the class exists
useExisting: forwardRef(() => CustomInputComponent)

What forwardRef actually does:

  1. Takes an arrow function () => CustomInputComponent and stores it without calling it.
  2. Returns a special marker that Angular's dependency injection understands.
  3. Later, once the module has finished loading and the class is fully defined, Angular invokes the function to get the real reference.

In other words, it's lazy evaluation: "Don't look up the class now β€” call this function to get it when you actually need it." By resolution time, the class exists.

Why the CVA pattern specifically triggers this: a component that registers itself under NG_VALUE_ACCESSOR is inherently circular β€” the class's own metadata needs a reference to the class before the class finishes initializing. forwardRef breaks that chicken-and-egg cycle.

When you need it (and when you don't):

SituationNeed forwardRef?
A component provides itself in its own providers (CVA, self-validator)βœ… Yes
Two classes inject each other (circular DI)βœ… Yes
Injecting a class/token declared later in the same fileβœ… Yes
Referencing a class already defined above the usage❌ No
Referencing something imported from another module❌ No

🧠 Mental model: forwardRef(() => X) means "give Angular a function it can call later to get X, instead of the value of X right now." Needed whenever X isn't defined yet at the point of reference.

Step 2 β€” Use it like any native control​

@Component({
selector: 'app-form',
standalone: true,
imports: [ReactiveFormsModule, CustomInputComponent],
template: `
<form [formGroup]="form">
<!-- βœ… Works exactly like a native input now -->
<app-custom-input formControlName="username"></app-custom-input>

<p>Value: {{ form.value.username }}</p>
<p>Touched: {{ form.controls.username.touched }}</p>
<p>Valid: {{ form.controls.username.valid }}</p>

<button (click)="form.controls.username.disable()">Disable</button>
<button (click)="form.reset()">Reset</button>
</form>
`,
})
export class FormComponent {
form = new FormGroup({
username: new FormControl('', Validators.required),
});
}

Notice everything just works: required validation, touched state, disable(), and reset() β€” because your component now honors the CVA contract.


⭐ A Real Example: Custom Star-Rating Control​

This shows CVA shining where a native input can't help at all.

import { Component, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
selector: 'app-star-rating',
standalone: true,
template: `
<div class="stars" [class.disabled]="disabled">
@for (star of stars; track star) {
<span
class="star"
[class.filled]="star <= value"
(click)="rate(star)"
(mouseenter)="hover = star"
(mouseleave)="hover = 0"
>
{{ (hover || value) >= star ? 'β˜…' : 'β˜†' }}
</span>
}
</div>
`,
styles: [`
.star { cursor: pointer; font-size: 1.8rem; color: #f5a623; }
.disabled .star { cursor: not-allowed; opacity: 0.5; }
`],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => StarRatingComponent),
multi: true,
},
],
})
export class StarRatingComponent implements ControlValueAccessor {
readonly stars = [1, 2, 3, 4, 5];
value = 0;
hover = 0;
disabled = false;

private onChange: (value: number) => void = () => {};
private onTouched: () => void = () => {};

writeValue(value: number): void {
this.value = value ?? 0;
}

registerOnChange(fn: (value: number) => void): void {
this.onChange = fn;
}

registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}

setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}

rate(star: number): void {
if (this.disabled) return;
this.value = star;
this.onChange(star); // view β†’ model
this.onTouched(); // mark as touched
}
}

Usage:

<form [formGroup]="reviewForm">
<app-star-rating formControlName="rating"></app-star-rating>
</form>
reviewForm = new FormGroup({
rating: new FormControl(0, [Validators.required, Validators.min(1)]),
});

Now the rating widget supports validation (min(1)), touched state for showing "please rate" errors, patchValue({ rating: 4 }), and reset() β€” all for free.


πŸ” The Data Flow, Visualized​


🧩 Bonus: Less Boilerplate with a Directive Host or Signals​

Avoiding forwardRef with a provider factory​

The forwardRef + provider block is repetitive. You can extract it into a reusable helper:

import { Provider, Type, forwardRef } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';

export function provideValueAccessor(component: Type<any>): Provider {
return {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => component),
multi: true,
};
}

// Usage:
// providers: [provideValueAccessor(StarRatingComponent)]

Adding validation from the same component​

Implement Validator and register NG_VALIDATORS alongside NG_VALUE_ACCESSOR if your control should carry its own validation logic:

import { NG_VALIDATORS, Validator, AbstractControl, ValidationErrors } from '@angular/forms';

providers: [
provideValueAccessor(StarRatingComponent),
{ provide: NG_VALIDATORS, useExisting: forwardRef(() => StarRatingComponent), multi: true },
]

// in the class
validate(control: AbstractControl): ValidationErrors | null {
return control.value >= 1 ? null : { required: true };
}

βœ… Best Practices & Gotchas​

  • Always multi: true on the NG_VALUE_ACCESSOR provider β€” it's a multi-provider token; forgetting it breaks binding.
  • Handle null/undefined in writeValue β€” forms pass null on reset(). Guard with ?? defaultValue.
  • Call onTouched() on blur/interaction end β€” otherwise touched never becomes true and error messages won't show.
  • Respect setDisabledState β€” don't let users interact when the control is disabled.
  • Don't call onChange inside writeValue β€” that would create a loop (modelβ†’viewβ†’model). writeValue should only update the display.
  • Prefer changeDetection: OnPush for performance; call markForCheck() if you mutate state outside Angular events.
  • Works with template-driven forms too β€” the same CVA makes [(ngModel)] work on your component.

🎯 Summary​

ControlValueAccessor is the bridge that lets a custom component behave like a native form control. Angular's FormControl is DOM-agnostic, so it needs a translator: writeValue pushes values model β†’ view, while registerOnChange/registerOnTouched push changes and touch state view β†’ model, and setDisabledState syncs the disabled state. You can't integrate a custom widget with formControlName, validation, or form state without it β€” that's exactly why Angular throws "No value accessor" when it's missing. Implement the four methods, register under NG_VALUE_ACCESSOR (with multi: true), and your component becomes a full citizen of Angular's Forms API. ⭐