Как работает ControlValueAccessor?

ControlValueAccessor в Angular — это интерфейс, позволяющий создать компонент, который может взаимодействовать с Angular Forms API (Reactive Forms или Template-Driven Forms) так, как это делают встроенные элементы управления (input, select, checkbox и др.). Это ключ к реализации кастомных компонентов ввода, полностью интегрированных с формами Angular.

1. Назначение

Без реализации ControlValueAccessor Angular не сможет:

  • привязать значение формы к компоненту;

  • уведомить форму об изменениях в значении;

  • управлять состоянием (disabled, touched);

  • синхронизировать ошибки валидации.

С помощью ControlValueAccessor вы говорите Angular: “Вот как мой компонент работает с моделью формы”.

2. Интерфейс ControlValueAccessor

Интерфейс содержит 4 метода:

interface ControlValueAccessor {
writeValue(obj: any): void;
registerOnChange(fn: any): void;
registerOnTouched(fn: any): void;
setDisabledState?(isDisabled: boolean): void;
}

3. Реализация пошагово

3.1 Создание кастомного компонента

@Component({
selector: 'app-custom-input',
template: \`<input \[value\]="value" (input)="onInput($event)" (blur)="onTouched()" />\`,
providers: \[{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CustomInputComponent),
multi: true
}\]
})
export class CustomInputComponent implements ControlValueAccessor {
value: string = '';
private onChange = (\_: any) => {};
private onTouched = () => {};
writeValue(value: any): void {
this.value = value;
}
registerOnChange(fn: any): void {
this.onChange = fn;
}
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
setDisabledState?(isDisabled: boolean): void {
// Реализуется при необходимости
}
onInput(event: Event) {
const newValue = (event.target as HTMLInputElement).value;
this.value = newValue;
this.onChange(newValue);
}
}

4. Пояснение к методам

  • writeValue(value: any): вызывается, когда Angular хочет передать значение компоненту (например, при инициализации формы).

  • registerOnChange(fn: Function): Angular передаёт функцию, которую необходимо вызывать при изменении значения в компоненте.

  • registerOnTouched(fn: Function): Angular передаёт функцию, которую нужно вызвать при потере фокуса (blur) для отслеживания touched-состояния.

  • setDisabledState?(isDisabled: boolean): необязательный метод, вызывается для активации/деактивации компонента. Должен применяться к disabled свойствам HTML-элементов.

5. Использование в форме

Reactive Forms:

form = new FormGroup({
username: new FormControl('')
});
<form \[formGroup\]="form">
<app-custom-input formControlName="username"></app-custom-input>
</form>

6. Множественные NG_VALUE_ACCESSOR

Так как в приложении могут быть несколько ControlValueAccessor, в providers нужно указать multi: true.

providers: \[{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CustomInputComponent),
multi: true
}\]

7. Обработка disabled

Если компонент использует внутренний <input>, то метод setDisabledState() должен его отключать:

setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
&lt;input \[disabled\]="disabled" /&gt;

8. Интеграция с валидацией

Если компонент должен поддерживать ngModel, formControl, formControlName — реализация ControlValueAccessor обязательна.

Для поддержки кастомной валидации добавляется Validator интерфейс и NG_VALIDATORS.

9. Использование Template-Driven Forms (ngModel)

&lt;app-custom-input \[(ngModel)\]="name"&gt;&lt;/app-custom-input&gt;

Angular вызывает writeValue, registerOnChange, registerOnTouched при связывании с ngModel.

10. Поддержка FormControl.setValue и patchValue

Метод writeValue() вызывается при любых операциях, изменяющих значение в форме.

this.form.get('username')?.setValue('admin');

Вызовет:

writeValue('admin')

11. Множественные компоненты: valueAccessor выбирается Angular

Если несколько директив/компонентов претендуют на роль ControlValueAccessor в одном и том же DOM-элементе, Angular выбрасывает ошибку NG0100: More than one custom value accessor.

Решение — оставить только один NG_VALUE_ACCESSOR для элемента или использовать useExisting с forwardRef.

12. Поддержка touched, dirty, pristine

  • registerOnTouched() нужно вызывать при blur событиях;

  • onChange() — при любом изменении значения.

Это позволяет Angular корректно отслеживать состояние формы.

13. ControlValueAccessor + ngModelOptions

Если используется ngModelOptions, Angular вызывает onChange в соответствии с указанными параметрами (например, { updateOn: 'blur' }).

14. Пример для checkbox

&lt;input type="checkbox" \[checked\]="value" (change)="toggle($event)" (blur)="onTouched()" /&gt;
value = false;
toggle(event: Event) {
const checked = (event.target as HTMLInputElement).checked;
this.value = checked;
this.onChange(checked);
}

15. Пример для select-мульти

&lt;select multiple (change)="onSelect($event)" \[value\]="value"&gt;
&lt;option \*ngFor="let item of options" \[value\]="item"&gt;{{ item }}&lt;/option&gt;
&lt;/select&gt;
onSelect(event: Event) {
const selectedOptions = Array.from((event.target as HTMLSelectElement).selectedOptions).map(o => o.value);
this.value = selectedOptions;
this.onChange(selectedOptions);
}

16. Проверка состояния

Можно в шаблоне использовать ngModel, formControl, formControlName, formGroup и получать доступ к валидности, touched-состоянию и пр.:

&lt;app-custom-input formControlName="email"&gt;&lt;/app-custom-input&gt;
&lt;div \*ngIf="form.get('email')?.touched && form.get('email')?.invalid"&gt;
Некорректный email
&lt;/div&gt;

ControlValueAccessor — это низкоуровневый API, дающий разработчику контроль над тем, как его компонент связывается с Angular Forms. Он делает возможным расширение функциональности форм Angular за счёт собственных UI-компонентов, сохраняя при этом преимущества реактивного подхода, валидации, привязки состояний и интеграции с форм-менеджментом.