Signal Forms в Angular

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

Цього місяця в Angular-спільноті відбулася справді цікава подія: в основну гілку фреймворку нарешті влили експериментальну фічу Signal Forms.

А чому це така цікава новина? — спитаєте ви. Адже ми ж роками працювали з реактивними формами й якось жили з цим.

Але от у чому річ: Signal Forms нарешті закривають цілу купу болючих моментів:

  • щоб створити навіть просту форму, треба багато boilerplate-коду FormGroup та FormControl;
  • ручні підписки на valueChanges, щоб UI залишався синхронним;
  • нескінченні if/else для виводу помилок;
  • дублювання логіки валідаторів у різних полях.

У цій статті я покажу, чому форми на сигналах — це справді новий підхід.

Базова форма

В основі нового Signal Forms API лежить функція form().

Замість того, щоб вручну збирати FormGroup і FormControl, тепер достатньо передати їй сигнал-модель (тобто ваш стан форми) — і Angular сам побудує для вас форму, яка працює на сигналах.

Візьмемо простий приклад — форму реєстраці на конференцію:

protected readonly registration = signal<ConferenceRegistration>({
  fullName: '',
  email: '',
  arrivalDate: new Date(),
  needAccommodation: false,
  accommodationNotes: ''
});

interface ConferenceRegistration {
  fullName: string;
  email: string;
  arrivalDate: Date;
  needAccommodation: boolean;
  accommodationNotes: string;
}

Далі все максимально просто — передаємо модель у form():

protected readonly registrationForm = form(this.registration);

І на цьому все — форма створена. Жодних «танців» із FormGroup та зайвим boilerplate-кодом.

Прив’язка інпутів через директиву

У шаблоні нам більше не потрібні formControlName чи ngModel. Тепер достатньо використати нову директиву [control], яка напряму з’єднує поле форми з HTML-елементом:

<h2>Conference Registration Form</h2>

<form (submit)="register(); $event.preventDefault()">
  <input [control]="registrationForm.fullName" placeholder="Ваше ім’я" type="text" />
  <input [control]="registrationForm.email" placeholder="Email" type="email" />
  <input [control]="registrationForm.arrivalDate" type="date" />

  <label>
    <span>Потрібне проживання?</span>
    <input [control]="registrationForm.needAccommodation" type="checkbox" />
  </label>

  @if (registration().needAccommodation) {
    <textarea [control]="registrationForm.accommodationNotes" placeholder="Деталі проживання"></textarea>
  }

  <button type="submit">Зареєструватися</button>
</form>

Директива [control] робить усе набагато прозорішим:

  • не потрібно писати зайві назви чи ідентифікатори для кожного поля;
  • не потрібно вручну зв’язувати форму з інпутами;
  • кожна властивість моделі автоматично «живе» у своєму інпуті.

Валідація

У Signal Forms правила перевірки можна задати прямо в form() — це робить код набагато чистішим.

Наприклад, додамо валідацію для полів fullName та email:

protected readonly registrationForm = form(this.registration, (path) => {
  required(path.fullName),
  minLength(path.fullName, 3),
  maxLength(path.fullName, 100),

  required(path.email),
  email(path.email)
});

У шаблоні можна вивести повідомлення про помилки для кожного поля:

<input [control]="registrationForm.fullName" placeholder="Ваше ім'я" type="text" />

@for(error of registrationForm.fullName().errors(); track error) {
  @if(error.kind === 'required') {
    <div class="validation-error">Це поле є обов'язковим</div>
  } @else if(error.kind === 'minLength') {
    <div class="validation-error">Мінімальна довжина — 3 символи</div>
  } @else if(error.kind === 'maxLength') {
    <div class="validation-error">Максимальна довжина — 100 символів</div>
  }
}

<input [control]="registrationForm.email" placeholder="Email" type="email" />
@for(error of registrationForm.email().errors(); track error) {
  @if(error.kind === 'required') {
    <div class="validation-error">Введіть email</div>
  } @else if(error.kind === 'email') {
    <div class="validation-error">Неправильний формат email</div>
  }
}

<button type="submit" [disabled]="!registrationForm().valid()">Зареєструватися</button>

Це працює, але є одна проблема: такий підхід виходить занадто оверхедним рішенням. З кожним новим валідатором кількість коду росте, а шаблон перетворюється в ад.

Менше if/else для помилок

Можна зробити набагато краще, повідомлення прив’язати безпосередньо до валідаторів. Тобто нам вже не потрібні купа if/else для виводу помилок — достатньо просто у циклі пробіжатись по всім повідомлення цього інпуту.

protected readonly registrationForm = form(this.registration, (path) => {
  required(path.fullName, { message: "Вкажіть ваше ім'я" }),
  minLength(path.fullName, 3, { message: "Мінімум 3 символи" }),
  maxLength(path.fullName, 100, { message: "Максимум 100 символів" }),
  required(path.email, { message: "Введіть email" }),
  email(path.email, { message: "Невірний формат email" })
});

Тепер шаблон виглядає набагато чистішим:

<input [control]="registrationForm.fullName" placeholder="Ваше ім’я" type="text" />
@for(error of registrationForm.fullName().errors(); track error) {
  <div class="validation-error">{{ error.message }}</div>
}

<input [control]="registrationForm.email" placeholder="Email" type="email" />
@for(error of registrationForm.email().errors(); track error) {
  <div class="validation-error">{{ error.message }}</div>
}
Приємний бонус: повідомлення не обов’язково мають бути «жорстко зашитим» текстом. Замість цього ви можете прокидати ключ перекладу, а ваша i18n-система (наприклад, Transloco) автоматично підтягне відповідний текст потрібною мовою.

Одна схема — кілька полів

У нас два поля — fullNameта email — можуть мати однакові правила валідації. Щоб не дублювати код, можна винести їх у схему і застосовувати повторно:

import { Schema, schema, apply, required, minLength, maxLength, email } from '@angular/forms/signals';

// Схема для текстових полів
const textSchema: Schema<string> = schema((fieldPath) => {
  required(fieldPath, { message: 'Це поле є обов'язковим' });
  minLength(fieldPath, 3, { message: 'Мінімум 3 символи' });
  maxLength(fieldPath, 100, { message: 'Максимум 100 символів' });
});

// Створюємо форму і застосовуємо схему до кількох полів
protected readonly registrationForm = form(this.registration, (fieldPath) => {
  apply(fieldPath.fullName, textSchema);
  apply(fieldPath.email, schema((f) => {
    // email поле має додаткову валідацію для формату
    apply(f, textSchema);          // наслідуємо базову схему тексту
    email(f, { message: 'Невірний формат email' }); // додатковий валідатор
  }));
});

Тепер валідація стала ще більш DRY.

Умовна валідація

Іноді правила валідації залежать від інших полів форми. Наприклад, у формі реєстрації учасника конференції поле accommodationNotes має бути обов’язковим тільки якщо учасник потребує проживання.

Раніше цеб означало підписку на valueChanges, ручне оновлення валідаторів і виклик updateValueAndValidity().

З Signal Forms все значно простіше: валідатори можуть отримувати умову when, яка визначає, коли вони повинні застосовуватися.

protected readonly registrationForm = form(this.registration, (fieldPath) => {
  // Базова валідація для імені та email
  ...

  // Валідація для деталей проживання
  required(fieldPath.accommodationNotes, {
    when: ({ valueOf }) => valueOf(fieldPath.needAccommodation) === true,
    message: 'Будь ласка, вкажіть деталі проживання'
  });
});

Шаблон форми буде виглядати наступним чином:

<label>
  <span>Потрібне проживання?</span>
  <input [control]="registrationForm.needAccommodation" type="checkbox" />
</label>

@if (registration().needAccommodation) {
  <textarea [control]="registrationForm.accommodationNotes" placeholder="Деталі проживання"></textarea>

  @for(error of registrationForm.accommodationNotes().errors(); track error) {
    <div class="validation-error">{{ error.message }}</div>
  }
}

Тепер форма вимагає введення деталей проживання тільки коли це дійсно потрібно!

Обробка сабміта

Форми не лише перевіряють дані, а й відправляють їх на бекенд. У Signal Forms для цього є зручні хелпери, давайте поглянемо на них.

submit() дозволяє:

  • Відслідковувати стан відправки (submitting)
  • Обробляти серверні помилки через систему помилок форми
  • Скидати форму після успішної відправки
protected register() {
  submit(this.registrationForm, async (form) => {
    try {
      await firstValueFrom(
        this.httpClient.post(
          'https://api.conference.com/registrations',
          JSON.stringify(this.registration())
        )
      );

      form().reset(); // очищаємо форму після успішної відправки
      return undefined;
    } catch (e) {
      // перетворюємо серверну помилку на помилку форми
      return [
        {
          kind: 'server',
          message: (e as Error).message,
        },
      ];
    }
  });
}

У шаблоні:

<button
  [disabled]="!registrationForm().valid() || registrationForm().submitting()"
  type="button"
  (click)="register()"
>
  Зареєструватися
</button>

Тепер кнопка активна лише коли форма валідна та ще не відправляється. Серверні помилки відображаються як частина форми, і після успішної відправки всі поля очищаються.

Шо по ітогу?

Signal Forms справді відчуваються як щось нове для Angular. Раніше робота з формами означала нескінченні FormGroup, підписки на valueChanges, дублювання логіки валідаторів і купа if/else для помилок. З сигналами усе це значно спрощується.

Нагадаю, це лише експериментальна версія в Angular v21.next і API продовжує розвиватися!

Буду вдячний за лайки, якщо вам сподобалась стаття 😊

👍ПодобаєтьсяСподобалось8
До обраногоВ обраному1
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

У меня из-за этого сигнала тестовое зарубили юзал вместе с >Net(Backend).

Делал так, в контексте .NET в качестве backend-сервиса можно использовать API, с которым Signal Forms на Angular был интегрироваться через HTTP-запросы (REST, GraphQL). Signal Forms работал на фронтенде и обеспечивают реактивное управление формами с минимальным количеством шаблонного кода и декларативной валидацией.

Вот общий подход как был использован Signal Forms с .NET backend:
Фронтенд (Angular с Signal Forms):

Определял модель формы как Signal.

Указывав валидаторы и правила с помощью нового API.

import {
  form,
  schema,
  required,
  email,
  minLength,
  max,
} from '@angular/forms/signals';

@Component({
  // ...
})
export class UserComponent {
  // Исходные данные формы в виде сигнала
  userData = signal({
    name: '',
    email: '',
    age: 0,
  });

  // Определение формы со схемой валидации
  userForm = form(this.userData, (user) => {
    required(user.name, { message: 'Name is required' });
    minLength(user.name, 3, { message: 'Name must be at least 3 characters' });

    required(user.email, { message: 'Email is required' });
    email(user.email, { message: 'Invalid email format' });

    max(user.age, 120, { message: 'Age cannot exceed 120' });
  });
}
Функции required, minLength, email, max — встроенные валидаторы Signal Forms.

Каждый валидатор принимает путь к полю и объект с сообщением об ошибке.

Валидация происходит реактивно благодаря сигнальному API.

Можно создавать асинхронные валидаторы с помощью функции validateAsync.

Использывал директиву [control] для связывания полей формы с UI.

Отправлял данные на backend через HTTP.

import { validateAsync, customError, resource, signal } from '@angular/forms/signals';
import { HttpClient } from '@angular/common/http';

@Component({
  // ...
})
export class AsyncValidationComponent {
  private http = inject(HttpClient);

  slugData = signal({ slug: '' });

  slugForm = form(this.slugData, (post) => {
    validateAsync(post.slug, {
      params: ({ value }) => ({ slug: value() }),
      factory: (paramsSignal) =>
        resource({
          params: () => paramsSignal(),
          loader: ({ params }) => {
            if (!params?.slug.trim()) return Promise.resolve(null);
            return this.http.post('/api/check-slug', params).toPromise();
          },
        }),
      errors: (result: { available?: boolean } | null) => {
        if (result && result.available === false) {
          return customError({ kind: 'slugTaken', message: 'Slug is already taken' });
        }
        return null;
      },
    });
  });
}


Бэкенд (.NET):

Принимал данные формы через контроллеры Web API.

Выполнял дополнительную валидацию и бизнес-логику.

Возвращал ответы об успешном сохранении или ошибках. Что я делал не так?
Скажите, а как оно для реакта будет? Это новый уровень асинхронности.
промисы-> асинк авейты->сигналы(если это синхронные реактивные переменные, отслеживающие своё состояние и автоматически обновляющие подписчиков при изменениях. Однако Signals сами по себе синхронны, то есть не возвращают Promise и не работают напрямую с асинхронными операциями.)->?

Насколько я понял из статьи получается, что для асинхронной логики с Signals вводят специальный API -Resource (в Angular) или вспомогательный API validateAsync для асинхронной валидации форм.
т.е. Signals управляют реактивностью синхронно, а асинхронные операции выполняются через Resource API, который возвращает обещание (Promise), обрабатывает данные и обновляет состояние Signals, сохраняя чистоту реактивного потока при передачи данных?

Так, cамі по собі Signals синхронні, вони просто тримають значення і сповіщають підписників при зміні. А для асинхронності в Angular придумали Resource, який обгортає async-логіку в реактивний шар: завантажує дані, трекає статус (loading / error / success) і синхронно оновлює сигнал зі станом результату.

У React поки прямого аналога Resource немає, там або useEffect + useState/useReducer, або зовнішні штуки типу React Query чи Zustand

А что я делал не так почему мне тестовое зарезали?

Нарешті дочекалися

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