Signal Forms: кінець ControlValueAccessor

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

Якщо ви коли-небудь писали кастомний контрол в 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.

Сподобалась стаття автора? Підписуйтесь на його акаунт вгорі сторінки, щоб отримувати сповіщення про нові публікації на пошту.

👍ПодобаєтьсяСподобалось5
До обраногоВ обраному2
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Топ, дякую за статтю.

Ну тепер залишилось тільки щоб компоненти створювалися якось типу

export function MyComponent() {
  const value = signal('Hello');
  return `{{value}}`;
}

Підписатись на коментарі