В основе любой зрелой дизайн-системы лежит набор универсальных и предсказуемых компонентов. Когда речь заходит о формах, ключевым элементом, отделяющим профессиональную библиотеку компонентов от набора «костылей», является реализация ControlValueAccessor.
Этот интерфейс - не просто «ещё одно API». Это фундаментальный контракт, который позволяет нашим UI-компонентам бесшовно интегрироваться в мощную экосистему Angular Forms, включая валидацию, управление состоянием и потоки данных.
В этой статье мы рассмотрим эталонную реализацию кастомного инпута. Мы не просто реализуем CVA, но и грамотно интегрируем NgControl для доступа к состоянию контрола, избегая при этом классических ловушек вроде циклических зависимостей. Цель - получить компонент, готовый к использованию в самых сложных и масштабируемых enterprise-приложениях.
Зачем ControlValueAccessor (CVA)
ControlValueAccessor
- это контракт между реактивной формой и вашим UI-контролом. Реализовав его, вы превращаете любой кастомный компонент в нативный контрол для реактивных форм: двусторонняя синхронизация значения, touched/dirty/disabled
, валидаторы, единый DX.
NG_VALUE_ACCESSOR: как «подключить» ваш компонент к формам
Angular ищет подходящий аксессор через DI-токен NG_VALUE_ACCESSOR
. Для кастомного компонента регистрируем себя:
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CustomInputComponent),
multi: true,
}]
multi: true
- у токена может быть несколько провайдеров.useExisting
- использовать уже созданный инстанс компонента в роли аксессора.
Внимание: В большинстве случаев forwardRef не требуется. Однако в данном паттерне, сочетающем CVA и NgControl, он является обязательным для разрешения циклической зависимости, которая может возникнуть на этапе компиляции.
Интерфейс CVA - кратко и по делу
interface ControlValueAccessor {
writeValue(obj: any): void; // модель -> вид
registerOnChange(fn: any): void; // вид -> модель (изменение)
registerOnTouched(fn: any): void; // вид -> модель (blur)
setDisabledState?(isDisabled: boolean): void; // disabled
}
Почему стоит получить NgControl внутри компонента
Состояния контрола (invalid
, touched
, dirty
, errors
, statusChanges
) живут в NgControl
. Если аккуратно получить NgControl
внутри компонента, можно:
отрисовывать ошибки и состояния прямо в шаблоне
навешивать классы/ARIA-атрибуты
не пробрасывать статусы извне.
Важно: прямой инжект NgControl
вызовет циклическую зависимость (NG200) и если компонент используют вне форм, прямой инжект сломается (не найдёт провайдера). Безопасный способ - через Injector.get
и self: true
: попросим NgControl
только у себя, и если его нет - вернётся null.
Современная реализация (Signals, OnPush, NgControl/Injector)
@Component({
selector: 'app-custom-input',
standalone: true,
imports: [CommonModule],
templateUrl: './custom-input.component.html',
styleUrls: ['./custom-input.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CustomInputComponent),
multi: true,
}],
})
export class CustomInputComponent implements ControlValueAccessor, OnInit {
// Публичный API
public id = input<string>('custom-input');
public label = input.required<string>();
public type = input<string>('text');
public placeholder = input<string>('');
public value = signal<string>('');
public disabled = signal<boolean>(false);
// Коллбеки от Angular Forms
public onChange: (value: string) => void = () => {};
public onTouched: () => void = () => {};
// Доступ к состоянию FormControl (если компонент используется в форме)
public ngControl: NgControl | null = null;
private injector = inject(Injector);
private inputEl = viewChild.required<ElementRef<HTMLInputElement>>('inputEl');
constructor() {
// Создаём эффект, который будет синхронизировать сигнал с DOM
effect(() =>
if (this.inputEl()) {
this.inputEl().nativeElement.value = this.value();
});
}
public ngOnInit(): void {
// Берём NgControl только из собственного инжектора;
// если компонента нет в форме - получим null
this.ngControl = this.injector.get(NgControl, null, { self: true });
}
public onInput(event: Event): void {
const newValue = (event.target as HTMLInputElement).value;
this.value.set(value);
this.onChange(newValue);
}
// ---- ControlValueAccessor ----
public writeValue(value: string): void {
this.value.set(value);
}
public registerOnChange(fn: (value: string) => void): void {
this.onChange = fn;
}
public registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
public setDisabledState(isDisabled: boolean): void {
this.disabled.set(isDisabled);
}
}
шаблон (с ARIA и ошибками)
<div class="input-container">
<label [attr.for]="id()">{{ label() }}</label>
<input
#inputEl
[id]="id()"
[type]="type()"
[placeholder]="placeholder()"
[disabled]="disabled()"
(input)="onInput($event)"
(blur)="onTouched()"
[attr.aria-invalid]="ngControl?.invalid && ngControl?.touched"
/>
</div>
Используем в реактивной форме
@Component({
selector: 'app-auth-form',
standalone: true,
imports: [ReactiveFormsModule, CustomInputComponent],
templateUrl: './auth-form.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AuthFormComponent implements OnInit {
private fb = inject(FormBuilder);
public submittedData = signal<any>(null);
public customForm = this.fb.group({
username: this.fb.control('', { validators: [Validators.required, Validators.minLength(3)] }),
email: this.fb.control('', { validators: [Validators.required, Validators.email] }),
});
public onSubmit(): void {
if (this.customForm.valid) {
this.submittedData.set(this.customForm.value);
console.log(this.customForm.value);
} else {
this.customForm.markAllAsTouched();
}
}
}
<form [formGroup]="customForm" (ngSubmit)="onSubmit()">
<app-custom-input
formControlName="username"
label="Username"
placeholder="Введите имя пользователя"
/>
<app-custom-input
formControlName="email"
label="Email"
type="email"
placeholder="Введите адрес электронной почты"
/>
<button type="submit" [disabled]="customForm.invalid">Submit</button>
</form>
@if (submittedData()) {
<div class="submitted">
<h3>Submitted Data:</h3>
<pre>{{ submittedData() | json }}</pre>
</div>
}
Как это связано «под капотом»
formControlName
через DI находит value accessor’ы поNG_VALUE_ACCESSOR
. Наш компонент - один из них.Angular вызывает
registerOnChange
/registerOnTouched
и сохраняет переданные функции.На инициализации/патчах формы Angular вызывает
writeValue
, а приdisable/enable
-setDisabledState
.Пользователь вводит текст → в
onInput
мы дергаемonChange(newValue)
→ обновляетсяFormControl
→ валидаторы/статусы/valueChanges
.Параллельно мы безопасно достали
NgControl
черезInjector.get(..., { self: true })
и используем его дляaria-invalid
, показа ошибок.
Почему такой подход идеален для переиспользования
Универсальность: один контрол для десятков форм, единое поведение и доступность (a11y).
Прозрачность состояний через
NgControl
: правильные ARIA-атрибуты и UX-сигналы.Простота с Signals: минимум кода, предсказуемые обновления, OnPush из коробки.
Расширяемость: легко добавить маски/форматирование,
NG_VALIDATORS
, асинхронные проверки, подсказки и т.п.
Заключение
ControlValueAccessor
даёт чистый контракт «форма ↔ контрол», а NgControl + Injector
- удобный доступ к статусам без хрупких костылей. В результате у вас получается переиспользуемый инпут: доступный, предсказуемый, тестопригодный и готовый к масштабированию.