Пишем переиспользуемые инпуты для реактивных форм с ControlValueAccessor + NgControl/Injector

от автора

В основе любой зрелой дизайн-системы лежит набор универсальных и предсказуемых компонентов. Когда речь заходит о формах, ключевым элементом, отделяющим профессиональную библиотеку компонентов от набора «костылей», является реализация 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> }

Как это связано «под капотом»

  1. formControlNameчерез DI находит value accessor’ы по NG_VALUE_ACCESSOR. Наш компонент — один из них.

  2. Angular вызываетregisterOnChange/registerOnTouchedи сохраняет переданные функции.

  3. На инициализации/патчах формы Angular вызываетwriteValue, а при disable/enablesetDisabledState.

  4. Пользователь вводит текст → вonInputмы дергаемonChange(newValue)→ обновляется FormControl → валидаторы/статусы/valueChanges.

  5. Параллельно мы безопасно достали NgControl через Injector.get(..., { self: true }) и используем его для aria-invalid, показа ошибок.

Почему такой подход идеален для переиспользования

  • Универсальность: один контрол для десятков форм, единое поведение и доступность (a11y).

  • Прозрачность состояний через NgControl: правильные ARIA-атрибуты и UX-сигналы.

  • Простота с Signals: минимум кода, предсказуемые обновления, OnPush из коробки.

  • Расширяемость: легко добавить маски/форматирование, NG_VALIDATORS, асинхронные проверки, подсказки и т.п.

Заключение

ControlValueAccessor даёт чистый контракт «форма ↔ контрол», а NgControl + Injector — удобный доступ к статусам без хрупких костылей. В результате у вас получается переиспользуемый инпут: доступный, предсказуемый, тестопригодный и готовый к масштабированию.


ссылка на оригинал статьи https://habr.com/ru/articles/946890/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *