ControlValueAccessor — Custom Form Components

ControlValueAccessor (CVA) is the Angular interface that lets a custom component participate in reactive and template-driven forms as a first-class form control — accepting values from [formControl], emitting changes back to the parent form, and correctly handling the disabled state. Without CVA, if you build a custom star-rating component, a rich-text editor, a tag input, or a colour picker, you cannot bind it to a FormControl. With CVA, your custom component becomes indistinguishable from a native <input> from the form’s perspective.

ControlValueAccessor Interface

Method Angular Calls It When Your Implementation Should
writeValue(value) The form control’s value changes programmatically (setValue, patchValue, reset) Update the component’s internal display to reflect the new value
registerOnChange(fn) The component is connected to a form control Store the function — call it whenever the user changes the value
registerOnTouched(fn) The component is connected to a form control Store the function — call it when the user leaves the control (blur)
setDisabledState(disabled) The form control is enabled or disabled programmatically Update the component’s UI to show enabled/disabled state

CVA Registration

Approach Code Notes
Traditional (provider array) providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => MyComp), multi: true }] Requires forwardRef to avoid circular reference
Modern (host injection) hostDirectives: [NgModel] — Angular 15+ shorthand Limited use — traditional provider still needed for most cases
Note: The forwardRef(() => MyComponent) is required in the NG_VALUE_ACCESSOR provider because the class is being referenced before it is fully defined — the providers array is evaluated before the class body. forwardRef defers the class reference until after the class is defined. Without it, you would get a runtime error about using a value before it is initialised.
Tip: Use inject(NgControl, { optional: true, self: true }) instead of the NG_VALUE_ACCESSOR provider approach in Angular 14+. Inject NgControl in the constructor, then set ngControl.valueAccessor = this. This avoids the circular reference of forwardRef entirely and is cleaner. The self: true option ensures you get the NgControl on this specific component, not a parent one.
Warning: Always call onChange(null) (or the appropriate empty value) when your component is reset, not just when the user interacts. If writeValue(null) is called with null and your component’s internal state is not reset to null, the form will think the value is the old non-null value while displaying empty — causing form validity to be wrong. Handle all falsy values in writeValue: if (!value) { this.internalValue = ''; return; }.

Complete CVA Examples

// ── Star rating custom form control ──────────────────────────────────────
import {
    Component, forwardRef, signal, inject, Input,
} from '@angular/core';
import {
    NG_VALUE_ACCESSOR, ControlValueAccessor,
} from '@angular/forms';
import { CommonModule } from '@angular/common';

@Component({
    selector:   'app-star-rating',
    standalone: true,
    imports:    [CommonModule],
    template: `
        <div class="stars" [class.stars--disabled]="isDisabled()"
             role="radiogroup" [attr.aria-label]="'Rating out of ' + max">
            @for (star of stars; track star) {
                <button type="button"
                        class="star"
                        [class.star--filled]="star <= (hovered() || value())"
                        [class.star--hovered]="star <= hovered()"
                        [attr.aria-label]="'Rate ' + star + ' out of ' + max"
                        [disabled]="isDisabled()"
                        (click)="onStarClick(star)"
                        (mouseenter)="hovered.set(star)"
                        (mouseleave)="hovered.set(0)"
                        (blur)="onBlur()">
                    ★
                </button>
            }
        </div>
    `,
    providers: [{
        provide:     NG_VALUE_ACCESSOR,
        useExisting: forwardRef(() => StarRatingComponent),
        multi:       true,
    }],
})
export class StarRatingComponent implements ControlValueAccessor {
    @Input() max = 5;

    stars     = Array.from({ length: 5 }, (_, i) => i + 1);
    value     = signal(0);
    hovered   = signal(0);
    isDisabled= signal(false);

    // Stored callbacks from Angular
    private onChange  = (_: number) => {};
    private onTouched = () => {};

    // Angular calls this when form control value changes programmatically
    writeValue(value: number | null): void {
        this.value.set(value ?? 0);
    }

    // Angular calls this once to give us the change notifier
    registerOnChange(fn: (v: number) => void): void {
        this.onChange = fn;
    }

    // Angular calls this once to give us the touched notifier
    registerOnTouched(fn: () => void): void {
        this.onTouched = fn;
    }

    // Angular calls this when form control is disabled/enabled
    setDisabledState(isDisabled: boolean): void {
        this.isDisabled.set(isDisabled);
    }

    onStarClick(star: number): void {
        if (this.isDisabled()) return;
        this.value.set(star);
        this.onChange(star);   // notify form of new value
    }

    onBlur(): void {
        this.onTouched();      // notify form that control was touched
    }
}

// Usage in reactive form:
// form = fb.group({ rating: [0, [Validators.min(1), Validators.required]] });
// Template: <app-star-rating formControlName="rating"></app-star-rating>

// ── Tag input custom form control ─────────────────────────────────────────
@Component({
    selector:   'app-tag-input',
    standalone: true,
    imports:    [CommonModule, ReactiveFormsModule],
    template: `
        <div class="tag-input" [class.tag-input--focused]="isFocused()">
            <span *ngFor="let tag of tags()"
                  class="tag">
                {{ tag }}
                <button type="button" (click)="removeTag(tag)"
                        [disabled]="isDisabled()">×</button>
            </span>
            <input #input
                   type="text"
                   [placeholder]="tags().length === 0 ? 'Add tags...' : ''"
                   [disabled]="isDisabled()"
                   (keydown.enter)="addTag(input.value); input.value = ''; $event.preventDefault()"
                   (keydown.backspace)="onBackspace(input.value)"
                   (focus)="isFocused.set(true)"
                   (blur)="isFocused.set(false); onTouched()">
        </div>
    `,
    providers: [{
        provide:     NG_VALUE_ACCESSOR,
        useExisting: forwardRef(() => TagInputComponent),
        multi:       true,
    }],
})
export class TagInputComponent implements ControlValueAccessor {
    @Input() maxTags = 10;
    @Input() separator = ',';

    tags       = signal<string[]>([]);
    isFocused  = signal(false);
    isDisabled = signal(false);

    private onChange  = (_: string[]) => {};
    onTouched = () => {};

    writeValue(value: string[] | null): void {
        this.tags.set(value ?? []);
    }

    registerOnChange(fn: (v: string[]) => void): void { this.onChange = fn; }
    registerOnTouched(fn: () => void): void            { this.onTouched = fn; }
    setDisabledState(v: boolean): void                 { this.isDisabled.set(v); }

    addTag(value: string): void {
        const tag = value.trim();
        if (!tag || this.tags().includes(tag) || this.tags().length >= this.maxTags) return;
        const newTags = [...this.tags(), tag];
        this.tags.set(newTags);
        this.onChange(newTags);
    }

    removeTag(tag: string): void {
        const newTags = this.tags().filter(t => t !== tag);
        this.tags.set(newTags);
        this.onChange(newTags);
    }

    onBackspace(inputValue: string): void {
        if (!inputValue && this.tags().length > 0) this.removeTag(this.tags().at(-1)!);
    }
}

// Usage:
// form = fb.group({ tags: [[], Validators.required] });
// Template: <app-tag-input formControlName="tags"></app-tag-input>

How It Works

Step 1 — NG_VALUE_ACCESSOR Registers the Component with Angular Forms

The NG_VALUE_ACCESSOR multi-provider token is the mechanism by which Angular knows a component can act as a form control. When Angular processes formControlName="rating" on an element, it looks up all providers registered with NG_VALUE_ACCESSOR on that element’s injector, finds your component’s class, and uses it as the bridge between the FormControl and the component’s UI.

Step 2 — writeValue Flows Outward (Form → Component)

When the form’s control value changes — through setValue(), patchValue(), reset(), or initial value — Angular calls writeValue(newValue) on your CVA. Your implementation updates the component’s internal display state. This is a one-way push from the form to the component. Never call onChange() from inside writeValue() — that would create an infinite loop.

Step 3 — onChange Flows Inward (Component → Form)

When the user interacts with your component (clicks a star, types a tag), call the stored onChange(newValue) function. This notifies the parent FormControl that the value has changed, which updates control.value, triggers validators, and marks the control as dirty. Angular provides this function via registerOnChange(fn) — store it and call it whenever the user changes the value.

Step 4 — onTouched Triggers Touched State

Call the stored onTouched() function when the user has “left” the control — on blur. This marks the FormControl as touched, which enables error message display in templates that use *ngIf="ctrl.invalid && ctrl.touched". Without calling onTouched(), validation errors never appear even when the control is invalid, because the control is never marked as touched.

Step 5 — setDisabledState Syncs the Disabled State

When control.disable() or control.enable() is called on the parent form control, Angular calls setDisabledState(true/false) on your CVA. Update your component’s visual state to show disabled/enabled — preventing user interaction when the control is disabled and restoring it when enabled. Without this method, calling form.disable() would visually leave your custom component active.

Common Mistakes

Mistake 1 — Calling onChange inside writeValue

❌ Wrong — infinite loop: writeValue → onChange → writeValue → onChange:

writeValue(value: string): void {
    this.internalValue = value;
    this.onChange(value);  // DO NOT call onChange here! Infinite loop!
}

✅ Correct — writeValue only updates internal state, never calls onChange:

writeValue(value: string): void {
    this.internalValue = value ?? '';  // just update the display
}

Mistake 2 — Not handling null/undefined in writeValue

❌ Wrong — crashes when form is reset or initial value is null:

writeValue(value: string): void {
    this.text = value.trim();  // TypeError: Cannot read property 'trim' of null
}

✅ Correct — always handle falsy values:

writeValue(value: string | null): void {
    this.text = value ?? '';
}

Mistake 3 — Not calling onTouched on blur — errors never show

❌ Wrong — validation errors never display because control never becomes touched:

<!-- No blur handler — user leaves the field but errors don't appear -->
<input (input)="onChange($event.target.value)">

✅ Correct — call onTouched on blur:

<input (input)="onChange($event.target.value)"
       (blur)="onTouched()">

Quick Reference

Task Code
Register as CVA providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => MyComp), multi: true }]
Store callbacks registerOnChange(fn) { this.onChange = fn } and registerOnTouched(fn) { this.onTouched = fn }
Value from form → component writeValue(v) { this.internal.set(v ?? default) }
Value from component → form this.onChange(newValue) in user event handler
Mark as touched this.onTouched() in blur handler
Handle disabled state setDisabledState(v) { this.isDisabled.set(v) }
Use in template <app-star-rating formControlName="rating"></app-star-rating>

🧠 Test Yourself

A custom CVA component has an internal rating signal. The form calls control.setValue(4). Which CVA method fires, and what should it do?