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

  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 - удобный доступ к статусам без хрупких костылей. В результате у вас получается переиспользуемый инпут: доступный, предсказуемый, тестопригодный и готовый к масштабированию.

Комментарии (0)