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

Признаюсь честно, что моя первая реакция на анонс 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? Словили баги или наоборот в восторге? Делитесь в комментариях.

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