Новые реактивные формы в Angular: Signal Forms API
- четверг, 19 февраля 2026 г. в 00:00:07

Привет! Меня зовут Незар, я фронтенд-разработчик из Т-Банка. В 21 релизе Angular команда разработчиков представила экспериментальное API для построения реактивных форм с помощью сигналов. В статье подробно его разберем.
Тому, кто знаком с классическими Reactive Forms в Angular, новый Signal Forms API покажется эволюционным шагом: те же мощные возможности, но с сигналами — а значит, с автоматической реактивностью, полной типизацией и меньшим количеством шаблонного кода.
Покажу, как с помощью сигналов теперь можно быстро создать типобезопасную форму, настроить валидацию с условными правилами, гибко управлять состояниями полей и легко встраивать кастомные компоненты-контролы. Сделаем код чище, а логику — прозрачнее. Присаживайтесь поудобнее, начинаем!
В демопроекте я использовал версию Angular 21.1.0. Рассмотренное в статье API экспериментальное, поэтому может быть нестабильным или измениться в новых релизах.
Представим, что нам необходимо создать форму регистрации пользователя со следующей моделью:
name — имя;
surname — фамилия;
email — электронная почта;
password — пароль;
confirmPassword — подтверждение пароля;
phone — вложенный объект:
notifyEnabled — признак включения уведомлений через телефон;
phoneNumber — номер телефона, необязательное поле, появляется при включении уведомлений через телефон.
Сначала создадим сигнал, соответствующий этой модели:
user = signal<User>({ name: '', surname: '', email: '', password: '', confirmPassword: '', phone: { notifyEnabled: false, phoneNumber: '', }, });
Потом напишем переменную формы, использовав функцию form:
import { form } from '@angular/forms/signals'; ... signupForm = form(this.user);
В form передается сигнал, представляющий модель формы. Благодаря использованию сигналов и типизированной модели сама форма также типизирована. Модель формы может быть плоской либо иметь вложенные объекты или массивы — контролы будут созданы автоматически с учетом структуры модели.
Поля формы представлены интерфейсом FieldState, в котором описаны такие API, как значение, состояния и валидация полей, а еще дополнительные методы по работе с контролами. Важно, что все свойства в этом интерфейсе представляют собой сигналы.
Перейдем к привязке формы с UI. Для этого импортируем директиву FormField из @angular/forms/signals в компонент и используем ее в шаблоне привязкой конкретного поля формы к input:
<form> <label>Имя</label> <input type="text" [formField]="signupForm.name" /> <label>Фамилия</label> <input type="text" [formField]="signupForm.surname" /> <label>Адрес электронной почты</label> <input type="email" [formField]="signupForm.email" /> <label>Пароль</label> <input type="password" [formField]="signupForm.password" /> <label>Повторите пароль</label> <input type="password" [formField]="signupForm.confirmPassword" /> <label >Получать уведомления на телефон <input type="checkbox" [formField]="signupForm.phone.notifyEnabled" /></label> @if(signupForm.phone.notifyEnabled().value()){ <label>Телефон</label> <input type="text" [formField]="signupForm.phone.phoneNumber" /> } <button type="submit">Зарегистрироваться</button> </form>
Мы напрямую указываем привязку к полю через эту директиву. Для поля с вводом номера телефона дополнительно повесим if, проверяющий включение чекбокса notifyEnabled через свойство value().

Значение формы полностью синхронизировано с сигналом, из которого форма была создана. Притом это двустороннее связывание и мы можем обновить сигнал, что приведет к автоматическому обновлению формы. Поскольку мы работаем с сигналами, все значения или состояния можно удобно обработать в computed или effect.
Форма есть, но пока она не имеет должной валидации. Давайте это исправим.
Валидация добавляется через второй аргумент функции form. С его помощью можно настроить состояние полей формы (валидацию, disabled-состояние и так далее). Для этого уже есть заготовленные правила-функции, которые можно легко добавить:
required — поле должно иметь значение;
email — значение поля совпадает с email-форматом;
min — минимальное значение для числовых полей;
max — максимальное значение для числовых полей;
minLength — минимальная длина значения (работает со строками и массивами);
maxLength — максимальная длина значения (работает со строками и массивами);
pattern — проверка значения поля по регулярному значению.
В эти функции передается поле, которое необходимо провалидировать:
import { required } from '@angular/forms/signals'; ... signupForm = form(this.user, (path) => { required(path.name); ... });
В нашем случае path — это вся форма signupForm. В required можно добавлять валидацию по условию через параметр when в конфигурации правила. Этот параметр получает FieldContext и возвращает значение true, если поле обязательное.
FieldContext позволяет получить доступ к значению и состоянию полей:
value — сигнал, содержащий значение текущего поля;
state — объект FieldState текущего поля;
fieldTree — текущее поле как узел дерева формы;
valueOf — функция для получения значения определенного поля по пути;
stateOf — функция для получения состояния определенного поля по пути;
fieldTreeOf — функция для получения поля по пути.
Используем проверку по значению через функцию valueOf:
signupForm = form(this.user, (path) => { ... required(path.phone.phoneNumber, { when: ({ valueOf }) => valueOf(path.phone.notifyEnabled) }); });
Выходит, что поле phoneNumber в форме будет обязательным, если есть значение другого поля (чекбокс notifyEnabled). Помимо значения через схему можно получить состояние поля через stateOf и само поле через fieldTreeOf.
Применим нужные валидаторы для наших полей согласно модели:
name: required;
surname: required;
email: required и email;
phoneNumber: required, только когда notifyEnabled включен и pattern -(/^((8|\+7)[\- ]?)?(\(?\d{3}\)?[\- ]?)?[\d\- ]{7,10}$/).
Для пароля используем кастомный валидатор. Делается это через функцию validate. Первым аргументом эта функция принимает поле, а вторым — функцию логики валидации с контекстом, через который можно получить доступ к другим полям (значения и состояния):
... required(path.password); pattern(path.password, /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,}$/); required(path.confirmPassword); validate(path.confirmPassword, ({ value, valueOf }) => { const confirmPassword = value(); const password = valueOf(path.password); if (confirmPassword !== password) { return { kind: 'passwordMismatch', message: 'Пароли не совпадают', }; } return null; }); ...
Если поле подтверждения пароля имеет значение, не похожее на значение поля password, будет ошибка типа passwordMismatch с сообщением «Пароли не совпадают», которое мы сможем вывести в UI.
Похожую логику можно сделать через функцию validateTree, которая позволяет настроить валидацию для множества полей сразу:
validateTree(path, ({ valueOf, fieldTreeOf }) => { const confirmPassword = valueOf(path.confirmPassword); const password = valueOf(path.password); if (confirmPassword !== password) { return [ { fieldTree: fieldTreeOf(path.password), kind: 'passwordMismatch', message: 'Пароли не совпадают', }, { fieldTree: fieldTreeOf(path.confirmPassword), kind: 'passwordMismatch', message: 'Пароли не совпадают', }, ]; } return null; });
Мы должны вернуть массив ошибок, указав соответствующие поля в fieldTree. Теперь же у нас одновременно провалидируются оба поля с паролем.
Вывод ошибок можно сделать двумя способами:
Пройтись по массиву ошибок через form.control.errors и проверять в if значение свойства kind (тип) ошибки валидации для указания соответствующего текста:
@if (signupForm.password().touched()) { @for (error of signupForm.password().errors(); track error.kind) { @if (error.kind === 'passwordMismatch') { <span>Пароли не совпадают</span> } } }
Указать текст ошибки прямо в схеме (поле message) и выводить его в шаблоне:
... required(path.name, { message: 'Это поле обязательно' }); required(path.surname, { message: 'Это поле обязательно' }); required(path.email, { message: 'Это поле обязательно' }); email(path.email, { message: 'Введите корректный адрес электронной почты' }); ...
@if (signupForm.surname().touched()) { @for (error of signupForm.surname().errors(); track error.kind) { <span>{{ error.message }}</span> } }
Второй вариант выглядит чище и предпочтительнее.

Валидацию можно вынести в отдельную переменную, чтобы переиспользовать правила для отдельных полей или целых форм. Для этого есть функция schema.
export const signupFormSchema = schema<User>((path) => { required(path.name, { message: 'Это поле обязательно' }); required(path.surname, { message: 'Это поле обязательно' }); required(path.email, { message: 'Это поле обязательно' }); email(path.email, { message: 'Введите корректный адрес электронной почты' }); required(path.password, { message: 'Это поле обязательно' }); pattern(path.password, /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,}$/, { message: 'Минимум 8 символов, одна заглавная и одна строчная буква', }); required(path.confirmPassword, { message: 'Это поле обязательно' }); validateTree(path, ({ valueOf, fieldTreeOf }) => { const confirmPassword = valueOf(path.confirmPassword); const password = valueOf(path.password); if (confirmPassword !== password) { return [ { fieldTree: fieldTreeOf(path.password), kind: 'passwordMismatch', message: 'Пароли не совпадают', }, { fieldTree: fieldTreeOf(path.confirmPassword), kind: 'passwordMismatch', message: 'Пароли не совпадают', }, ]; } return null; }); required(path.phone.phoneNumber, { when: ({ valueOf }) => valueOf(path.phone.notifyEnabled), message: 'Это поле обязательно', }); pattern(path.phone.phoneNumber, /^((8|\+7)[\- ]?)?(\(?\d{3}\)?[\- ]?)?[\d\- ]{7,10}$/, { message: 'Введите корректный номер телефона', }); });
Можно применить созданную ранее схему (signupFormSchema) в форме вторым аргументом:
signupForm = form(this.user, signupFormSchema);
Кроме валидации можно обработать и другие состояния формы для настройки более гибкого UI.
Состояния формы можно разделить на три категории: валидацию, интерактивность и доступность.
Формы валидации:
valid — поле успешно проходит правила валидации;
invalid — поле имеет ошибки валидации;
errors — массив объектов ошибок валидации;
pending — выполняется асинхронная валидация.
Формы интерактивности:
touched — поле потеряло фокус;
dirty — поле было изменено пользователем.
Формы доступности:
disabled — поле отключено;
hidden — поле должно быть скрыто в UI;
readonly — поле недоступно для редактирования.
Все эти свойства — сигналы, их можно обработать в UI для конкретного поля или всей формы в целом. Все состояния доступности не влияют на валидацию. При получении значения формы или полей (value) они всегда будут возвращены, даже если отключены. Раньше, например, старые отключенные (disabled) реактивные поля не возвращались при обращении к значению (value) формы.
В схему помимо валидации можно добавить правила для доступности полей в том же виде, что и функции валидации:
disabled(path.surname, ({ valueOf }) => valueOf(path.name).length < 3); readonly(path.surname, ({ valueOf }) => valueOf(path.name).length < 3);
Импорт функций disabled и readonly происходит из @angular/forms/signals. Для них необязательно передавать вторым аргументом функцию, возвращающую boolean-условие. Правило применится и так. А функция hidden второй аргумент ожидает обязательно.
Для поля телефона вместо проверки значения чекбокса в HTML можно указать правило hidden:
hidden(path.phone.phoneNumber, ({ valueOf }) => !valueOf(path.phone.notifyEnabled)); required(path.phone.phoneNumber, { message: 'Это поле обязательно', });
Мы добавили скрытие поля телефона, если не выбран чекбокс получения уведомлений по телефону, а также убрали when из required, поскольку скрытые поля не участвуют в валидации.
В HTML поправим условие отображения поля телефона на проверку hidden-состояния поля:
@if (!signupForm.phone.phoneNumber().hidden()) { <label>Телефон</label> ... }
Таким образом, новое API позволяет более гибко определять разные состояния формы и обрабатывать их в UI.
Наша форма почти готова — осталось научиться отправлять значение формы.
Для отправки формы используется функция submit, которая принимает форму и асинхронную логику, которая будет применена (Promise):
import { submit } from '@angular/forms/signals'; ... createUser(event: Event){ event.preventDefault(); submit(this.signupForm, async (form) => { try { await Promise.resolve({ success: true }); form().reset({ name: '', surname: '', email: '', password: '', confirmPassword: '', phone: { notifyEnabled: false, phoneNumber: '', }, }); return undefined; } catch (error) { // Обработка ошибки } }); }
Мы сразу резолвим Promise, но в реальном проекте на этом месте будет, например, запрос на сервер с введенными данными. На успешное выполнение асинхронной операции нужно возвращать null или undefined. Удобно, что действие, переданное в функцию submit, выполнится, только если форма валидна. В коде мы дополнительно сбрасываем значение формы к дефолтному состоянию через функцию form().reset(), передав туда изначальную модель.
При возникновении ошибки ее можно обработать и вернуть с привязкой сразу к конкретному полю для отображения в UI.
catch (error) { return [ { fieldTree: form.email, kind: 'server', message: 'Пользователь с таким адресом электронной почты уже существует', }, ]; }

Поскольку отправка формы — асинхронная операция, мы можем отследить состояние отправки формы, используя свойство form().submitting(), и привязать его, например, на disabled-состояние кнопки.
<button [disabled]="signupForm().submitting()" type="submit" > Создать </button>
Теперь можно сказать, что наша форма полностью готова. Но кое-что осталось.
Мы не разобрали новый подход к созданию своих компонентов-контролов. Добавим возможность выбора пола в нашу форму, разработав такой контрол. Он будет состоять из двух кнопок, по клику на которые изменится значение модели. Для этого создадим новый компонент и реализуем интерфейс FormValueControl. От нас требуется лишь определить переменную value через model-сигнал в компоненте.
import { FormValueControl } from '@angular/forms/signals'; ... interface GenderType { id: string; name: string; } export class GenderControl implements FormValueControl<GenderType | null> { value = model<GenderType | null>(null); }
По клику на определенную кнопку значения контрола вызовем функцию value.set и установим новое значение. После этого надо добавить наш компонент в родительскую форму через уже знакомую нам директиву formField.
<gender-control [formField]="signupForm.gender" />
Теперь значение в gender-control будет синхронизировано с родительской формой. Оно обновляется в обе стороны. Можно задать начальное значение в родительской форме либо обработать это в кастомном контроле.
При этом интерфейс FormValueControl позволяет нам получить доступ ко множеству API, связанных с состоянием контрола, которые были разобраны выше. Их достаточно добавить через входной параметр компонента (сигнал) и завязаться на этих значениях в UI или логике (например, чтобы применить нужную стилизацию в HTML).
disabled = input(false); required = input(false);
Дополним правила в схему формы:
required(path.gender); disabled(path.gender, ({ valueOf }) => !valueOf(path.name) || !valueOf(path.surname));
При required будем красить label-поля в красный цвет, а при disabled будем отключать выбор и делать текст полупрозрачным.

На этом наша настройка формы завершена. Кстати, для чекбоксов логика схожая: требуется лишь реализовать интерфейс FormCheckboxControl со свойством checked в компоненте, а остальное будет таким же.
Signal Forms API — мощный инструмент для создания реактивных форм в Angular. По сравнению с модулем Reactive Forms и ControlValueAccessor разработка форм стала проще: есть типизация, автоматическая синхронизация через сигналы и более гибкая обработка валидации и состояний. Особенно удобны декларативная настройка правил формы и упрощенное создание кастомных контролов через FormValueControl и FormCheckboxControl. Хотя API пока экспериментальный, его изучение помогает подготовиться к будущему Angular, где сигналы станут основой реактивности.