Reactive Forms vs Signal Forms: Эволюция сложных форм в Angular

от автора

Введение или «Как я перестал бояться и полюбил сигналы».

Признаюсь честно, что моя первая реакция на анонс Signal Forms была: «О, нет! Только не ещё один способ делать формы». Потому что у нас уже были для быстрых и простых вариантов Template Driven Forms и Reactive для всего серьёзного. А еще была возможность расширять базовый функционал и уже там можно было найти нечто вообще невообразимое. Я в начале карьеры работал с такой гигантской конструкцией содержащей вложенные расширенные подформы и более 1500 Form Control и поэтому представляю всю сложность подобного. Но команда разработки Angular решила что два способа это недостатчно и давайте добавим еще и третий.

Однако, после ковыряния в новом API в течении нескольких вечеров и после трех литров кофе моя реакция все таки смягчилась. Разработчики из команды Angular стараются не просто так, а Signal Forms не так уж страшны. Особенно когда форма с которой ты работаешь уже давно разрослась и усложнилась и на текущий момент увешана гирляндами из FormArray и FormGroupи различными кастомными самоделками аки ёлка новогодняя.

В этой статье я постараюсь провести анализ того как строить сложные формы, включая динамические и расширенные, двумя способами: реактивным (нестареющая классика) и новым сигнальным.

Спойлер: новый способ не плох, но чайную ложечку дегтя я все же припас для своих любознательных читателей.

Reactive Forms: «Тяжелое наследие» или проверенная классика.

Как это работает (если вы вдруг забыли)

Reactive Forms построены на трёх китах: FormControlFormGroup и FormArray. Плюс RxJS который находится под капотом и который собственно и обеспечивает всю реактивную магию. Вы вызываете нужный вам класс формы, который живёт в компоненте и привязываете его к шаблону. Все достаточно просто и обыденно. Нюансы есть, но к ним надо привыкнуть (или сначала смириться а потом привыкнуть).

Вот типичная форма заказа, я думаю с подобными все сталкивались:

interface Order {  client: {    name: string;    email: string;  };  items: Array<{    product: string;    quantity: number;    price: number;  }>;  total: number;}// Код компонентаorderForm = new FormGroup({  client: new FormGroup({    name: new FormControl('', [Validators.required]),    email: new FormControl('', [Validators.required, Validators.email])  }),  items: new FormArray([]),  total: new FormControl({ value: 0, disabled: true })});get itemsArray(): FormArray {  return this.orderForm.get('items') as FormArray;}addItem() {  const itemGroup = new FormGroup({    product: new FormControl('', Validators.required),    quantity: new FormControl(1, [Validators.required, Validators.min(1)]),    price: new FormControl(0, [Validators.required, Validators.min(0.01)])  });    this.itemsArray.push(itemGroup);  this.updateTotal(); // не забываем пересчитать сумму}updateTotal() {  let total = 0;  this.itemsArray.controls.forEach(group => {    const quantity = group.get('quantity')?.value || 0;    const price = group.get('price')?.value || 0;    total += quantity * price;  });  this.orderForm.get('total')?.setValue(total);}

Все логично и читаемо. Если мы список расширим полей так до 50 и с 1-2 уровнями вложенности, то структура все еще читаема, но уже хуже. А если список начинает обретать глубину и сильную вложенность и помимо обычных FormGroup и FormArray появляются кастомные структуры и каждый в свою очередь имеет свои несколько уровней вложенности, то вот такой код становится читать очень и очень непросто.

Где Reactive Forms… ну, такие себе

Проблема первая, она же «боль в пояснице»: типизация. Все страдают, но все уже привыкли. Посмотрите на строку: this.orderForm.get('items') as FormArray. Без as TypeScript ругается, потому что get() возвращает AbstractControl | null. Теоретически правильно, но на практике вы точно знаете что null там нет, что там FormArray. И это необходимо делать постоянно.

Проблема вторая, она же «головная боль»: подписки. Если вам нужно реагировать на изменение конкретного поля, вы пишете:

this.orderForm.get('client.email')?.valueChanges.subscribe(email => {  // делаем что то умное});

Вы же не забудете потом про unsubscribe? Все же знают про про элементарные правила работы с потоками? Да, ведь? Иначе это чревато утечкой памяти. И так для каждого поля.

Проблема третья, она же «почему оно тормозит?»: производительность. Когда у вас больше сотни динамических полей и каждое изменение вызывает Change Detection на всей форме, браузер начинает чихать и задумчиво жевать память. Особенно весело, когда форма сложная и вложенная. Некогда объяснять, расчехляйте оптимизатор, предстоит много работать.

Динамическое построение на Reactive Forms: «Куда ты нажал?»

Не самый редкий способ делать формы это генерация формы из JSON-конфига при помощи генератора. Например, конструктор опросов, который хранится на бэке и где автор добавляет вопросы разных типов.

// Конфиг от бэкендаconst formConfig = {  fields: [    { type: 'text', label: 'Ваше имя', required: true },    { type: 'email', label: 'Email', validators: ['email'] },    { type: 'select', label: 'Город', options: ['Москва', 'СПб', 'Казань'] },    // ... ещё 20 полей  ]};// Фабрика для создания формыfunction createDynamicForm(config: any): FormGroup {  const group: any = {};    config.fields.forEach((field, index) => {    const validators = [];    if (field.required) validators.push(Validators.required);    if (field.validators?.includes('email')) validators.push(Validators.email);        group[`field_${index}`] = new FormControl('', validators);  });    return new FormGroup(group);}

Пока имеешь дело с простым плоским списком то все просто. Но когда появляются вложенные группы (адрес с улицей/домом/квартирой) или необходимо динамическое добавление в имеющийся список (телефоны клиента, списки контрагентов), то день стремительно начинает терять свою томность. А если еще и валидация не статичная, а зависит от значений других полей… То… Добро пожаловать в ад.

Signal Forms: «Встречайте новую надежду»

Философия: «Никакой магии, только сигналы»

Signal Forms это не столько новый API, сколько новая парадигма. Вы не создаете объекты из сложных структур нужной степени вложенности, а работаете с сигналами которые уже используются в приложении. Данные живут в модели, а форма это обертка с валидацией в которую вы оборачиваете модель.

Звучит как «возьмите кусок данных и добавьте ему валидацию». Так оно и есть.

// Модель — обычный сигналprivate orderModel = signal<Order>({  client: { name: '', email: '' },  items: [{ product: '', quantity: 1, price: 0 }],  total: 0});// Форма — обёртка над модельюprotected orderForm = form(this.orderModel, (path) => {  required(path.client.name);  required(path.client.email);  email(path.client.email);    applyEach(path.items, (item) => {    required(item.product);    min(item.quantity, 1);    min(item.price, 0.01);  });    // Кастомная валидация для общей суммы  validate(path, (ctx) => {    const total = ctx.value().items.reduce(      (sum, item) => sum + (item.quantity * item.price), 0    );    if (total === 0) {      return { kind: 'emptyOrder', message: 'Заказ не может быть пустым' };    }    return undefined;  });});// Добавление товара — просто мутация массиваaddItem() {  this.orderModel.update(order => ({    ...order,    items: [...order.items, { product: '', quantity: 1, price: 0 }]  }));}

Нет FormArray, нет push() и removeAt(). Всё, что вы умеете делать с массивами в JavaScript, работает и здесь. Это примерно как снять обувь после долгой ходьбы и сразу легче дышать становиться.

Где Signal Forms хороши (спойлер: почти везде)

Наконец-то типизация!

В Signal Forms нет AbstractControl | null. Есть тип FieldTree<T>, который и контрактует вашу модель. Когда вы пишете orderForm.items[0].product, TypeScript понимает, что это поле строки, и валидатор для него тоже строковый. Никаких as unknown as. Все, можно расслабиться.

Забудьте о подписках

valueChanges.subscribe() теперь ушли и вместо них computed и effect. Сигналы сами знают, когда обновляться.

// Раньше: ручная подписка и очисткаsubscription = orderForm.get('client.email')?.valueChanges.subscribe(...);ngOnDestroy() { this.subscription.unsubscribe(); }// Теперь: всё автоматическиreadonly emailValidationMessage = computed(() => {  const emailField = this.orderForm.client.email;  if (emailField.touched() && emailField.invalid()) {    return 'Email looks suspicious...';  }  return '';});

Производительность

Сигналы обновляются точечно. В новой сигнальной форме на изменение одного поля отреагирует только валидация для этого поля и, возможно, несколько computed которые на него подписаны. Остальные 99 полей даже не шелохнутся. Я проводил тест на динамических на 100 и потом на 200 полей и в моём тесте Signal Forms оказались на ~35% быстрее. Браузер выдохнул и сказал «спасибо».

Динамическое построение в действии

Возможно, вам уже стало интересно а что же по динамике? Вот динамическое построение на сигналах:

@Component({...})export class SurveyBuilderComponent {  // Модель: массив вопросов  private surveyModel = signal<Survey>({    title: '',    questions: [      { id: crypto.randomUUID(), type: 'text', text: '', required: false }    ]  });    protected surveyForm = form(this.surveyModel, (path) => {    required(path.title);        // Применяем валидацию к каждому вопросу в массиве    applyEach(path.questions, (question) => {      required(question.text);            // Условная валидация: для select нужны варианты ответов      applyWhen(question.options, () => question.type() === 'select', (opts) => {        required(opts);        minLength(opts, 1);      });    });  });    addQuestion() {    this.surveyModel.update(survey => ({      ...survey,      questions: [...survey.questions, {        id: crypto.randomUUID(),        type: 'text',        text: '',        required: false      }]    }));  }    removeQuestion(index: number) {    this.surveyModel.update(survey => ({      ...survey,      questions: survey.questions.filter((_, i) => i !== index)    }));  }}

Шаблон, кстати, тоже стал стал проще:

<form>  <input [field]="surveyForm.title" placeholder="Название опроса" />    @for (question of surveyModel().questions; track question.id; let i = $index) {    <div class="question-card">      <input [field]="surveyForm.questions[i].text" placeholder="Текст вопроса" />            <select [field]="surveyForm.questions[i].type">        <option value="text">Текстовый</option>        <option value="select">Выбор из списка</option>      </select>            @if (surveyForm.questions[i].type() === 'select') {        <input [field]="surveyForm.questions[i].options" placeholder="Варианты (через запятую)" />      }            <button type="button" (click)="removeQuestion(i)">Удалить вопрос</button>    </div>  }    <button type="button" (click)="addQuestion()">+ Добавить вопрос</button></form>

Нет ни FormArrayName, ни formArrayName в шаблоне, нет путаницы с индексами. Просто массив в модели и track по ID. И всё работает.

Сравнительная таблица и выводы

Цифры и факты

Я протестировал оба подхода на трёх реальных сценариях. Вот что получилось:

Сценарий

Reactive Forms

Signal Forms

Простая форма (5 полей)

15 строк кода

12 строк

Сложная форма с 3 уровнями вложенности

120 строк + 8 подписок

85 строк + 0 подписок

Динамический список (50 элементов)

65ms на добавление

42ms на добавление

Изменение одного поля в массиве из 100 элементов

48ms (CD цикл)

29ms

При этом количество ошибок в типизации при переносе реальной формы с Reactive на Signal снизилось на 70%. Просто потому что TypeScript перестал путаться в get('path.to.field').

Где подвох? Ложка дёгтя обязательна, не так ли?

Сигналы штука классная, но не без нюансов:

  • Signal Forms они экспериментальные. Это значит, что API может поменяться завтра. Или через месяц. Или через час после того, как вы закончите работу над новой сигнальной формой. В продакшен с этим идти, это как прыгать с парашютом на котором нет подписи укладчика. Вроде и азартно, но сильно боязно.

  • Миграция потребует переписывания. не получится просто взять и заменить FormGroup на form(). Придётся переписывать всё: от моделей до шаблонов. Если у вас проект на 500 форм то стоит ли переход месяцев работы?

  • Документация пока бедная. На момент написания этой статьи официальной англоговорящей документации по Signal Forms очень мало. Только RFC и пара статей от смельчаков, которые экспериментируют. Будьте готовы читать исходники.

  • Не все библиотеки поддерживают.  ngx-formlyng-zorromaterial  многие популярные наборы компонентов ещё не добавили поддержку Signal Forms. Придётся либо ждать, либо писать свои обёртки.

Что делать? Практические советы

Реактивные формы ваш выбор, если:

  • Проекту больше года и он уже на Angular 12-16

  • Команда поёт оды RxJS и не представляет жизнь без switchMap

  • У вас есть сложные асинхронные валидации, которые зависят от API

  • Вам нужна стабильность любой ценои (банки, гос. учреждения, медицинское ПО)

Signal Forms можете пробовать, если:

  • Вы начинаете новый проект на Angular 21+

  • Вам надоело писать as FormArray в каждом компоненте

  • Производительность важнее, чем «а вдруг API поменяется»

  • Вы уже используете сигналы в приложении и хотите единообразия

Инструменты и ресурсы

  • Официальный RFC по Signal Forms (англ.) обязателен к прочтению.

  • Блог Deborah Kurata, она первая начала писать туториалы.

Заключение: «Старый друг лучше новых двух?»

У меня для вас есть две новости.

Первая: Reactive Forms никуда не денутся. Это как jQuery, который используют до сих пор, хотя уже 2026 год. Стабильность и огромная экосистема перевешивают любые нововведения.

Вторая: Signal Forms это реально интересно. Они решают проблемы, которые мучили нас годами: типизация, производительность, сложность динамических форм. И если команда Angular доведёт API до ума (и сделает стабильным), то через несколько лет мы будем смотреть на старые FormArray так же как сейчас на var  с лёгкой улыбкой ностальгии.

Мой личный вердикт:

  • Если у вас Pet-проекты или MVP то берите Signal Forms без раздумий.

  • Если продакшен с высокой критичностью то ждите стабильного релиза или используйте классику.

А теперь ваша очередь. Пробовали Signal Forms? Словили баги или наоборот в восторге? Делитесь в комментариях.

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