DEV Community

Cover image for Mastering Angular ControlValueAccessor (CVA): The Complete Guide with Real-World Examples
Abanoub Kerols
Abanoub Kerols

Posted on

Mastering Angular ControlValueAccessor (CVA): The Complete Guide with Real-World Examples

Angular provides many built-in form controls such as <input>, <textarea>, <select>, and checkboxes. These elements work seamlessly with both Template-driven Forms and Reactive Forms because Angular already knows how to communicate with them.

But what happens when you build your own reusable component?

Imagine creating:

  • A star rating component ⭐
  • A color picker 🎨
  • A date range selector 📅
  • A phone number input 📞
  • A rich text editor ✍️

By default, Angular has no idea how these components should exchange values with a form.

This is exactly why ControlValueAccessor (CVA) exists.

What is ControlValueAccessor?
ControlValueAccessor is an Angular interface that acts as a bridge between:

  • Angular Forms API
  • Your custom component

It teaches Angular how to:

  • Write values into your component
  • Read values from your component
  • Mark the component as touched
  • Enable or disable the component

Without CVA, Angular treats your component as just another HTML element.

With CVA, Angular treats it exactly like a native <input>.

Think of CVA Like a Translator

Imagine two people speaking different languages.

  • Angular Forms speaks one language.
  • Your custom component speaks another.

ControlValueAccessor translates between them.

Reactive Form
      |
      |
ControlValueAccessor
      |
      |
Custom Component
Enter fullscreen mode Exit fullscreen mode

Why Do We Need It?
Suppose you build this component:
<app-counter></app-counter>

<form [formGroup]="form">

    <app-counter formControlName="quantity"></app-counter>

</form>
Enter fullscreen mode Exit fullscreen mode

Angular immediately throws an error:
No value accessor for form control with name: 'quantity'

Why?

Because Angular doesn't know:

  • How to pass the value
  • How to receive updates
  • When the control becomes touched
  • How to disable it

CVA solves all of this.

The ControlValueAccessor Interface
The interface looks like this:

export interface ControlValueAccessor {

    writeValue(obj: any): void;

    registerOnChange(fn: any): void;

    registerOnTouched(fn: any): void;

    setDisabledState?(isDisabled: boolean): void;

}
Enter fullscreen mode Exit fullscreen mode

Only four methods.

But each has an important responsibility.

Method 1 — writeValue()
Angular calls this whenever the form value changes.

Example:

this.form.patchValue({
    quantity: 10
});
Enter fullscreen mode Exit fullscreen mode

Angular internally executes:
component.writeValue(10);

Your implementation:

writeValue(value: number): void {
    this.value = value;
}
Enter fullscreen mode Exit fullscreen mode

Think of it as:
Form ➜ Component

Method 2 — registerOnChange()
Angular gives you a callback function.

Whenever your component value changes, you must call it.

registerOnChange(fn: any): void {
    this.onChange = fn;
}
Enter fullscreen mode Exit fullscreen mode

Later:

increment() {
    this.value++;
    this.onChange(this.value);
}
Enter fullscreen mode Exit fullscreen mode

Flow:

User Clicks
      ↓
Component Updates
      ↓
onChange(value)
      ↓
Angular Form Updates
Enter fullscreen mode Exit fullscreen mode

Think of it as:
Component ➜ Form

Method 3 — registerOnTouched()
Angular also provides another callback.

This tells Angular that the user interacted with the control.

registerOnTouched(fn: any): void {
    this.onTouched = fn;
}
Enter fullscreen mode Exit fullscreen mode

When should you call it?

Usually on:

  • blur
  • click
  • focus lost
  • first interaction

Example:

onBlur() {
    this.onTouched();
}
Enter fullscreen mode Exit fullscreen mode

Now Angular knows:
control.touched === true

Method 4 — setDisabledState()
When someone writes:

control.disable();

Angular calls:

setDisabledState(true);

Implementation

setDisabledState(isDisabled: boolean): void {
    this.disabled = isDisabled;
}
Enter fullscreen mode Exit fullscreen mode

Then your template:
<button [disabled]="disabled">
Now disabling works exactly like native inputs.

The Data Flow

Form.setValue()
        |
        |
writeValue()
        |
        |
Custom Component
        |
User Interaction
        |
        |
onChange()
        |
        |
Reactive Form
Enter fullscreen mode Exit fullscreen mode

A Complete Counter Component
counter.component.ts

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

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

@Component({
    selector: 'app-counter',

    templateUrl: './counter.component.html',

    providers: [
        {
            provide: NG_VALUE_ACCESSOR,
            useExisting: forwardRef(() => CounterComponent),
            multi: true
        }
    ]
})
export class CounterComponent implements ControlValueAccessor {

    value = 0;

    disabled = false;

    onChange = (_: number) => {};

    onTouched = () => {};

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

    registerOnChange(fn: any): void {
        this.onChange = fn;
    }

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

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

    increment() {
        if (this.disabled) return;

        this.value++;

        this.onChange(this.value);

        this.onTouched();
    }

    decrement() {
        if (this.disabled) return;

        this.value--;

        this.onChange(this.value);

        this.onTouched();
    }

}
Enter fullscreen mode Exit fullscreen mode

counter.component.html

<button (click)="decrement()" [disabled]="disabled">
    -
</button>

<span>{{ value }}</span>

<button (click)="increment()" [disabled]="disabled">
    +
</button>
Enter fullscreen mode Exit fullscreen mode

Parent Component

form = this.fb.group({

    quantity: [5]

});
Enter fullscreen mode Exit fullscreen mode
<form [formGroup]="form">

    <app-counter formControlName="quantity"></app-counter>

</form>
Enter fullscreen mode Exit fullscreen mode

Everything now works exactly like:
<input formControlName="quantity">

Understanding the Lifecycle
When the page loads:

Angular Creates Component
        ↓
registerOnChange()
        ↓
registerOnTouched()
        ↓
writeValue(initialValue)
Enter fullscreen mode Exit fullscreen mode

When the user clicks "+" :

value++
      ↓
onChange(value)
      ↓
FormControl Updated
Enter fullscreen mode Exit fullscreen mode

When the form resets:

form.reset()
      ↓
writeValue(null)
      ↓
Component Updated
Enter fullscreen mode Exit fullscreen mode

When disabled:

control.disable()
       ↓
setDisabledState(true)
Enter fullscreen mode Exit fullscreen mode

Common Mistakes

Mistake #1

Changing the value without calling onChange

Wrong:
this.value++;

Correct:

this.value++;

this.onChange(this.value);
Enter fullscreen mode Exit fullscreen mode

Mistake #2
Never calling onTouched

Wrong:

click() {
    this.value++;
}
Enter fullscreen mode Exit fullscreen mode

Correct:

click() {

    this.value++;

    this.onTouched();

}
Enter fullscreen mode Exit fullscreen mode

Mistake #3
Ignoring disabled state

Wrong

increment() {

    this.value++;

}
Enter fullscreen mode Exit fullscreen mode

Correct:
if (this.disabled) return;

Mistake #4
Forgetting NG_VALUE_ACCESSOR

Without:

providers: [
    ...
]
Enter fullscreen mode Exit fullscreen mode

Angular cannot discover your accessor.

CVA with Validators

CVA only handles value communication.

Validation is separate.

You can still use:

Validators.required

Validators.min(1)

Validators.max(10)
Enter fullscreen mode Exit fullscreen mode

Example:

quantity: new FormControl(
    1,
    Validators.min(1)
)
Enter fullscreen mode Exit fullscreen mode

Angular validates normally.

CVA + Signals (Angular 17+)
Instead of:
value = 0;

You can use:
value = signal(0);

Writing value:

writeValue(value: number) {
    this.value.set(value);
}
Enter fullscreen mode Exit fullscreen mode

Updating:

increment() {

    this.value.update(v => v + 1);

    this.onChange(this.value());

}
Enter fullscreen mode Exit fullscreen mode

Signals work perfectly with CVA and make state updates more declarative.

Real-World Components That Use CVA
Almost every reusable form component benefits from implementing ControlValueAccessor:

  • Custom dropdowns
  • Searchable selects
  • Multi-select components
  • Tag inputs
  • Date pickers
  • Time pickers
  • Color pickers
  • File uploaders
  • Phone number inputs
  • OTP inputs
  • Rich text editors
  • Sliders
  • Range selectors
  • Rating components
  • Toggle switches
  • Tree selectors
  • Custom checkboxes
  • Custom radio buttons
  • Markdown editors

Best Practices

  • Keep writeValue() free of side effects; update internal state only.
  • Always call onChange()when the value changes due to user interaction, not when writeValue() is invoked by Angular.
  • CallonTouched() when the user has meaningfully interacted with the control (often on blur, sometimes on the first interaction depending on the UX).
  • Respect the disabled state in both your component logic and template.
  • Expose a single source of truth for the value to avoid synchronization bugs.
  • Combine CVA with OnPushchange detection or Signals for better performance in reusable components.
  • Make your component accessible by supporting keyboard navigation, focus management, and ARIA attributes.

Final Thoughts
ControlValueAccessor is one of the most important APIs for building professional Angular applications. It allows your custom components to integrate seamlessly with Angular Forms, making them behave exactly like native form controls. By correctly implementing writeValue(), registerOnChange(), registerOnTouched(), and setDisabledState(), you unlock support for Reactive Forms, Template-driven Forms, validation, form resets, disabling, and all the other powerful features of Angular's forms ecosystem.

Mastering CVA is essential if you want to create reusable UI libraries, design systems, or enterprise-grade Angular applications where custom form controls are a common requirement. Once you understand the data flow between Angular Forms and your component, implementing new form controls becomes a predictable and repeatable process.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.