Signal Forms: кінець ControlValueAccessor
Якщо ви коли-небудь писали кастомний контрол в Angular через ControlValueAccessor, то скоріш за все, досі маєте невеличкий стрес, коли бачите слово «registerOnChange».
Що там було? Чотири методи, обов’язкові токени, та ще й усе це треба руками синхронізувати. Класика...
Але в Angular тепер нова еволюція — Signal Forms.
Тепер, щоб зробити свій кастомний контрол, не треба курити мануали по NG_VALUE_ACCESSOR і молитися стародавнім богам.
Створюємо компонент Payment Method Picker
Сценарій простий: ми маємо форму оформлення замовлення, і користувач має обрати спосіб оплати — card, apple-pay або cash.
export type PaymentMethod = 'card' | 'apple-pay' | 'cash' | null;
export type PaymentMethodInfo = {
type: PaymentMethod;
title: string;
icon: string;
};
Компонент:
@Component({
selector: 'app-payment-method-picker',
template: `
@for (method of paymentMethods; track method.type) {
<div class="payment-method-card">
<img
[src]="method.icon"
[alt]="method.title"
/>
<span>{{ method.title }}</span>
</div>
}
`,
})
export class PaymentMethodPicker {
protected readonly paymentMethods: PaymentMethodInfo[] = [
{ type: 'card', icon: 'card.png', title: 'Credit Card' },
{ type: 'apple-pay', icon: 'applepay.png', title: 'Apple Pay' },
{ type: 'cash', icon: 'cash.png', title: 'Cash' },
];
}
Поки що компонент просто малює картки, але ще не реагує на кліки.
Додаємо у форму
<h2>Checkout Form</h2> <form> <input [control]="checkoutForm.name" placeholder="Full Name" type="text" /> <input [control]="checkoutForm.email" placeholder="Email" type="email" /> <input [control]="checkoutForm.total" placeholder="Total Amount" type="number" /> <app-payment-method-picker /> <button type="button" (click)="submitOrder()"> Submit </button> </form>
І десь у коді:
@Component({
selector: 'app-signal-forms-example',
standalone: true,
imports: [Control, PaymentMethodPicker],
templateUrl: './signal-forms-example.component.html',
})
export class SignalFormsExampleComponent {
protected readonly order = signal({
name: '',
email: '',
total: 0,
paymentMethod: null,
});
protected readonly checkoutForm = form(this.order);
}
Але якщо спробувати ось так 👇
<app-payment-method-picker [control]="checkoutForm.paymentMethod" />
— отримаємо помилку.
Бо Angular очікує, що цей елемент «поводиться» як input — має value, disabled, required тощо.
У класичних формах це вирішувалося через ControlValueAccessor, але...
Забуваємо про CVA і використовуємо FormValueControl
Отут і починається магія Signal Forms.
Тепер, щоб зробити компонент формовим контролом, треба просто реалізувати FormValueControl<T> і додати одну властивість — value.
@Component({
selector: 'app-payment-method-picker',
template: `
@for (method of paymentMethods; track method.type) {
<div
class="payment-method-card"
[class.selected]="method.type === value()"
(click)="value.set(method.type)"
>
<img
[src]="method.icon"
[alt]="method.title"
/>
<span>{{ method.title }}</span>
</div>
}
`,
})
export class PaymentMethodPicker implements FormValueControl<PaymentMethod> {
public readonly value = model<PaymentMethod>('card');
protected readonly paymentMethods: PaymentMethodInfo[] = [
{ type: 'card', icon: 'card.png', title: 'Credit Card' },
{ type: 'apple-pay', icon: 'applepay.png', title: 'Apple Pay' },
{ type: 'cash', icon: 'cash.png', title: 'Cash' },
];
}
Все!

Без registerOnChange, без NG_VALUE_ACCESSOR, без магії. Клікнули на картку → value оновилась. Форма ініціалізувалась із даними → value синхронізувалася назад.
Двосторонній зв’язок працює автоматично.
Шо там по disabled
Ми хочемо, щоб контрол був неактивним, поки не введено email:
protected readonly checkoutForm = form(this.order, (path) => {
disabled(path.paymentMethod, ({ valueOf }) => !valueOf(path.email));
});
І в компоненті просто додаємо:
export class PaymentMethodPicker implements FormValueControl<PaymentMethod> {
public readonly value = model<PaymentMethod>('card');
public readonly disabled = input(false);
}
Все, Angular сам підхопить цей стан.

Можна навіть у шаблоні підсвітити:
<div class="payment-method-card" [class.disabled]="disabled()"> ... </div>
Без жодних ручних toggle. Це все — реактивно!
Required?
export class PaymentMethodPicker implements FormValueControl<PaymentMethod> {
public readonly value = model<PaymentMethod>('card');
public readonly disabled = input(false);
public readonly required = input(false);
}
І в шаблоні:
@if (required()) {
<span class="required-marker">*</span>
}
Як це працює?

Бо FormValueControl зробили реактивним із самого початку. Angular тепер не «стучить тобі у компонент», а просто дивиться на сигнали.
Ніяких registerOnChange, onTouched, writeValue. Просто властивості value, disabled, required, які синхронізуються автоматично.
Висновок
Замість того щоб писати 30 рядків бойлерплейту, ми зробили все в кілька простих кроків. І контрол став повноцінним членом форми — з підтримкою value, disabled, required, і навіть без providers.
Коротше, Signal Forms — це, мабуть, найприємніше, що трапилося з Angular формами з часів FormGroup.
Сподобалась стаття автора? Підписуйтесь на його акаунт вгорі сторінки, щоб отримувати сповіщення про нові публікації на пошту.
2 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів