Формы в Angular: от Reactive Forms к Signal Forms
- среда, 15 апреля 2026 г. в 00:00:07

Привет! Меня зовут Егор Молчанов, я разработчик в компании Домклик.
В прошлой статье мы познакомились с новыми функциями input(), output() и model(), которые закладывают фундамент для будущего Angular без Zone.js. Сегодня поговорим о том, как эти изменения дошли до самой, пожалуй, наболевшей темы в любом приложении — работы с формами.
В Angular v21 появился новый, экспериментальный способ управления формами — Signal Forms. Он не заменяет привычные Reactive Forms, а предлагает совершенно иной подход, основанный на сигналах. Давайте разберёмся, как формы работали раньше, как они будут работать с Signal Forms, и что это нам даёт.
Reactive Forms — это мощный, декларативный подход, который уже давно существует в Angular. В его основе лежат абстрактные классы (FormControl, FormGroup, FormArray). Мы создаём модель формы в компоненте, а затем связываем её с шаблоном. Главная фишка заключается в том, что вся логика и состояние формы находятся в компоненте, что делает её предсказуемой и легко тестируемой.
Давайте посмотрим на типичный пример формы для добавления книги, написанной с помощью Reactive Forms:
import { Component } from '@angular/core'; import { FormGroup, FormControl, Validators, ReactiveFormsModule, FormArray } from '@angular/forms'; @Component({ selector: 'app-book', imports: [ReactiveFormsModule], template: ` <div [formGroup]="bookForm"> <input formControlName="name" placeholder="Название книги" /> @if (bookForm.get('name')?.invalid && bookForm.get('name')?.touched) { <span> @if (bookForm.get('name')?.hasError('required')) { Название обязательно } </span> } <input formControlName="rating" type="number" placeholder="Рейтинг" /> @if (bookForm.get('rating')?.invalid && bookForm.get('rating')?.touched) { <span> @if (bookForm.get('rating')?.hasError('min')) { Рейтинг должен быть от 0 } @if (bookForm.get('rating')?.hasError('max')) { Рейтинг должен быть до 5 } </span> } <div formArrayName="pages"> @for (page of pages.controls; track $index) { <div [formGroupName]="$index"> <input formControlName="title" placeholder="Заголовок страницы" /> <textarea formControlName="description" placeholder="Описание"></textarea> @if (page.get('title')?.hasError('required')) { <span> Заголовок страницы обязателен </span> } </div> } </div> <button (click)="addPage()">Добавить страницу</button> <button (click)="onSave()" [disabled]="bookForm.invalid">Сохранить книгу</button> </div> ` }) export class BookFormComponent { bookForm = new FormGroup({ name: new FormControl('', [Validators.required]), rating: new FormControl(0, [Validators.min(0), Validators.max(5)]), pages: new FormArray([]) }); get pages() { return this.bookForm.get('pages') as FormArray; } addPage() { this.pages.push(new FormGroup({ title: new FormControl('', [Validators.required]), description: new FormControl('') })); } onSave() { console.log(this.bookForm.value); } }
Здесь мы видим классическую картину:
Создание модели формы: FormGroup, FormControl, FormArray
Синхронизация с шаблоном: [formGroup] и formControlName и formArrayName
Проверка: передаётся вторым аргументом в FormControl
Динамические массивы: для добавления страниц приходится создавать геттер pages, работать с FormArray и вручную пушить новые FormGroup
Код работает, но у него есть нюансы, которые со временем начинают раздражать:
Шаблонность: много кода для создания модели формы, особенно с FormArray
Разрозненность логики: проверка и состояние разбросаны между компонентом и шаблоном
Громоздкий доступ: this.bookForm.get('pages')?.get('0')?.get('title')?.value — это выглядит ужасно, но такова реальность
Отслеживание изменений: Reactive Forms полагаются на собственную систему обновлений, которая, хоть и эффективна, существует параллельно с сигналами
Теперь посмотрим, как та же задача решается с помощью Signal Forms. Вместо создания абстрактных классов мы создаём модель данных — обычный сигнал, а затем оборачиваем его в форму.
import { Component, signal } from '@angular/core'; import { form, FormField, required, min, max, applyEach } from '@angular/forms/signals'; @Component({ selector: 'app-book', imports: [FormField], template: ` <div> <input [formField]="bookForm.name" placeholder="Название книги" /> @if (bookForm.name().touched() && bookForm.name().invalid()) { @for (error of bookForm.name().errors(); track error.kind) { <span>{{ error.message }}</span> } } <input [formField]="bookForm.rating" type="number" placeholder="Рейтинг" /> @if (bookForm.rating().touched() && bookForm.rating().invalid()) { @for (error of bookForm.rating().errors(); track error.kind) { <span>{{ error.message }}</span> } } <div> @for (page of bookData().pages; track $index) { @let pageForm = bookForm.pages[$index]; <div> <input [formField]="pageForm.title" placeholder="Заголовок" /> <textarea [formField]="pageForm.description" placeholder="Описание"></textarea> </div> @for (error of pageForm.title().errors(); track error.kind) { <span>{{ error.message }}</span> } } <button (click)="addPage()">Добавить страницу</button> <button (click)="onSave()" [disabled]="bookForm().invalid()">Сохранить книгу</button> </div> </div> ` }) export class BookFormComponent { bookData = signal({ name: '', rating: 0, pages: [{ title: '', description: '' }] }); bookForm = form(this.bookData, (schemaPath) => { required(schemaPath.name, { message: 'Название книги обязательно' }); min(schemaPath.rating, 0, { message: 'Рейтинг не может быть меньше 0' }); max(schemaPath.rating, 5, { message: 'Рейтинг не может быть больше 5' }); applyEach(schemaPath.pages, (pageSchema) => { required(pageSchema.title, { message: 'Заголовок страницы обязателен' }); }); }); addPage() { this.bookData.update(data => ({ ...data, pages: [...data.pages, { title: '', description: '' }] })); } onSave() { console.log(this.bookData()); } }
Разберём этот код по частям.
Вместо FormGroup у нас есть обычный сигнал bookData. Это единственный источник истины. Если мы захотим заполнить форму данными из API, то просто делаем this.bookData.set(book). Вся форма тут же отобразит новые значения.
loadBook(): void { this.apiBook.subscribe((book) => this.bookData.set(book)) }
Функция form() принимает наш сигнал bookData и на его основе создаёт полевое дерево bookForm. Оно обладает двумя важными свойствами:
Доступ к полям: bookForm.name, bookForm.rating и bookForm.pages — это не FormControl, а специальный объект FieldTree, который даёт нам доступ к состоянию конкретного поля
Состояние поля: чтобы получить состояние поля, нужно вызвать его как функцию, например, bookForm.name(), что вернёт объект FieldState, содержащий реактивные сигналы:
value() — значение поля
invalid(), valid(), errors() — состояние проверки
touched(), dirty() — состояние взаимодействия
pending() — статус асинхронной валидации
Проверка для всех полей собирается в одном месте — во втором аргументе функции form(). Она задаётся декларативно с помощью встроенных хелперов, таких как required(), min(), max() и т.д.
bookForm = form(this.bookData, (schemaPath) => { required(schemaPath.name, { message: 'Название книги обязательно' }); min(schemaPath.rating, 0, { message: 'Рейтинг не может быть меньше 0' }); max(schemaPath.rating, 5, { message: 'Рейтинг не может быть больше 5' }); applyEach(schemaPath.pages, (pageSchema) => { required(pageSchema.title, { message: 'Заголовок страницы обязателен' }); }); });
Такой подход не только централизует логику, но и делает её невероятно удобной для повторного использования и тестирования.
Главная магия происходит в шаблоне. Директива [formField] обеспечивает автоматическую двустороннюю синхронизацию между DOM‑элементом и нашим полевым деревом.
<input [formField]="bookForm.name" />
Это аналогично [(ngModel)]="bookForm.name().value()", но гораздо мощнее. [formField] автоматически применяет к элементу атрибуты валидации (required, min, max ), управляет состоянием disabled/readonly и передаёт ошибки. [formField] также можно сравнить с model(): работает по тому же принципу двусторонней привязки, но [formField] на уровне всей формы.
Благодаря тому, что состояние поля доступно в виде сигналов, шаблон становится более выразительным и типобезопасным.
<!-- Проверяем, было ли поле тронуто и является ли невалидным --> @if (bookForm.name().touched() && bookForm.name().invalid()) { <!-- Проходим по всем ошибкам поля --> @for (error of bookForm.name().errors(); track error.kind) { <span>{{ error.message }}</span> } } <!-- Проверяем общее состояние формы --> <button (click)="onSend()" [disabled]="bookForm().invalid()">Сохранить книгу</button>
Вызов bookForm().invalid() возвращает состояние всей формы, что позволяет нам управлять кнопкой отправки без лишнего кода.
В нашей форме уже есть массив страниц. В Reactive Forms для этого мы бы использовали FormArray. В Signal Forms это делается ещё проще, так как наша модель — это обычный сигнал с массивом объектов.
@Component({ selector: 'app-book', imports: [FormField], template: ` <div> @for (page of bookData().pages; track $index) { @let pageForm = bookForm.pages[$index]; <div> <input [formField]="pageForm.title" placeholder="Заголовок" /> <textarea [formField]="pageForm.description" placeholder="Описание"></textarea> </div> @for (error of pageForm.title().errors(); track error.kind) { <span>{{ error.message }}</span> } } </div> ` }) export class BookFormComponent { bookData = signal({ pages: [{ title: '', description: '' }] }); bookForm = form(this.bookData, (schemaPath) => { applyEach(schemaPath.pages, (pageSchema) => { required(pageSchema.title, { message: 'Заголовок страницы обязателен' }); }); }); }
Несколько ключевых моментов из примера выше:
Работа с массивом в шаблоне: bookForm.pages[$index].title . Благодаря FieldTree мы можем обращаться к элементам массива по индексу так же естественно, как и к обычным полям объекта.
Валидация массива: функция applyEach() позволяет применить набор валидаторов к каждому элементу массива. Это гораздо удобнее, чем создавать FormArray и пушить в него новые FormGroup.
Динамическое изменение: добавление новой страницы — это простое обновление сигнала bookData:
addPage() { this.bookData.update(data => ({ ...data, pages: [...data.pages, { title: '', description: '' }] })); }
При этом applyEach() автоматически применит валидаторы и к новой странице.
Signal Forms упрощают асинхронную валидацию, например, проверку уникальности названия книги. В отличие от Reactive Forms, где требуется создавать асинхронный валидатор, в Signal Forms используется хелпер validateHttp .
import { validateHttp } from '@angular/forms/signals'; bookForm = form(this.bookData, (schemaPath) => { required(schemaPath.name); validateHttp(schemaPath.name, { request: ({ value }) => `/api/check-book-name?name=${value()}`, onSuccess: (response) => { if (response.isTaken) { return { kind: 'nameTaken', message: 'Книга с таким названием уже существует' }; } return null; }, onError: () => ({ kind: 'serverError', message: 'Не удалось проверить название, попробуйте позже', }), }); });
В шаблоне остаётся лишь отобразить индикатор загрузки, пока идёт проверка:
<input [formField]="bookForm.name" placeholder="Название книги" /> @if (bookForm.name().pending()) { <span>Проверяем название...</span> } @if (bookForm.name().touched() && bookForm.name().invalid()) { @for (error of bookForm.name().errors(); track error.kind) { <span>{{ error.message }}</span> } }
Angular сам отменит предыдущий запрос, если пользователь продолжит печатать, и будет ждать окончания синхронных валидаторов (например, required), прежде чем сделать HTTP‑запрос.
Signal Forms позволяют не просто задавать статичные правила валидации, но и делать их реактивными, то есть меняющими своё поведение в зависимости от значений других полей. Такой подход открывает новые возможности, которые в Reactive Forms были реализованы более громоздко.
Допустим, мы хотим, чтобы поле с рейтингом книги становилось обязательным только в том случае, если книга называется «Сказка о рыбаке и рыбке». Или наоборот, поле с названием становится обязательным только при определённом рейтинге. Это легко сделать с помощью параметра when.
export class BookFormComponent { bookData = signal<BookData>({ name: '', rating: 0, pages: [{ title: '', description: '' }] }); bookForm = form(this.bookData, (schemaPath) => { required(schemaPath.name, { message: 'При рейтинге 3 название книги обязательно', when: ({ valueOf }) => valueOf(schemaPath.rating) === 3 }); required(schemaPath.rating, { message: 'Для книги "Сказка о рыбаке и рыбке" рейтинг обязателен', when: ({ valueOf }) => valueOf(schemaPath.name) === 'Сказка о рыбаке и рыбке' }); }); }
Параметр when получает функцию, в которую через деструктуризацию передаётся valueOf. Этот метод позволяет «заглядывать» в значения других полей формы. Как только значение schemaPath.rating или schemaPath.name меняется, условие пересчитывается, и валидатор автоматически либо включается, либо выключается.
Бывают ситуации, когда встроенных валидаторов (required, min, max, email и т.д.) недостаточно. Например, если нужно запретить определённое сочетание полей или реализовать сложную бизнес‑логику, используется функция validate().
bookForm = form(this.bookData, (schemaPath) => { validate(schemaPath.rating, ({ value, valueOf, stateOf }) => { const rating = value(); const bookName = valueOf(schemaPath.name); const nameTouched = stateOf(schemaPath.name).touched(); if (!nameTouched) { return null; } if (bookName === 'Книга' && rating === 0) { return { kind: 'minRating', message: 'Для книги с названием "Книга" рейтинг не может быть 0' }; } if (rating > 4 && bookName.length < 3) { return { kind: 'shortNameForHighRating', message: 'Книге с высоким рейтингом стоит дать более длинное название' }; } return null; }); });
В кастомном валидаторе мы получаем объект с полезными методами:
value(): возвращает текущее значение поля, к которому применяется валидатор
valueOf(field): позволяет получить значение любого другого поля формы
stateOf(field) : позволяет получить полное состояние другого поля (ошибки, touched и т.д.)
Вы можете сделать проверку, которая зависит не только от значений других полей, но и от их состояния: например, был ли пользователь в поле, есть ли в нём уже ошибки. Вся эта логика собирается в одном месте и работает реактивно. При любом изменении зависимостей валидация перезапускается автоматически. Никаких ручных подписок иupdateValueAndValidity — просто декларативно опишите правила, и форма сама позаботится об их соблюдении.
Signal Forms — это не просто новая форма, а смена парадигмы. Мы уходим от абстрактных классов (FormControl, FormGroup, FormArray) в сторону простых и понятных сигналов. Вместо того чтобы строить модель формы, мы просто объявляем модель данных.
Что вы получаете
Меньше кода: не нужно создавать FormGroup, FormControl и связывать их с помощью formControlName, поскольку модель данных и форма создаются через простые функции signal() и form()
Централизованная логика: проверка, состояние disabled, readonly и hidden описываются в одном месте — в схеме формы
Полная типобезопасность: благодаря работе с сигналами и строгой типизации TypeScript вы получаете автодополнение и защиту от ошибок во всём коде — от компонента до шаблона
Удобство для динамических форм: работа с динамическими массивами становится элементарной — просто обновляете сигнал с массивом, и форма реагирует
Реактивность на стероидах: используя computed и effect, вы можете легко создавать производные состояния и подписываться на изменения в любом месте приложения
Signal Forms пока находятся на стадии эксперимента, но уже очевидно, что это значительный шаг вперёд. Они идеально вписываются в общую стратегию развития Angular в сторону сигналов. Если вы начинаете новый проект, обязательно обратите на них внимание. Для существующих проектов на Reactive Forms срочная миграция не требуется, но новый подход определённо заслуживает того, чтобы взять его на вооружение в будущем.
Пробуйте, экспериментируйте и делитесь своим опытом!