Введение или «Как я перестал бояться и полюбил сигналы».
Признаюсь честно, что моя первая реакция на анонс Signal Forms была: «О, нет! Только не ещё один способ делать формы». Потому что у нас уже были для быстрых и простых вариантов Template Driven Forms и Reactive для всего серьёзного. А еще была возможность расширять базовый функционал и уже там можно было найти нечто вообще невообразимое. Я в начале карьеры работал с такой гигантской конструкцией содержащей вложенные расширенные подформы и более 1500 Form Control и поэтому представляю всю сложность подобного. Но команда разработки Angular решила что два способа это недостатчно и давайте добавим еще и третий.
Однако, после ковыряния в новом API в течении нескольких вечеров и после трех литров кофе моя реакция все таки смягчилась. Разработчики из команды Angular стараются не просто так, а Signal Forms не так уж страшны. Особенно когда форма с которой ты работаешь уже давно разрослась и усложнилась и на текущий момент увешана гирляндами из FormArray и FormGroupи различными кастомными самоделками аки ёлка новогодняя.
В этой статье я постараюсь провести анализ того как строить сложные формы, включая динамические и расширенные, двумя способами: реактивным (нестареющая классика) и новым сигнальным.
Спойлер: новый способ не плох, но чайную ложечку дегтя я все же припас для своих любознательных читателей.
Reactive Forms: «Тяжелое наследие» или проверенная классика.
Как это работает (если вы вдруг забыли)
Reactive Forms построены на трёх китах: FormControl, FormGroup и 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-formly,ng-zorro,materialмногие популярные наборы компонентов ещё не добавили поддержку 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? Словили баги или наоборот в восторге? Делитесь в комментариях.
