19 ноября 2025 года команда Angular выпустила 21 версию фреймворка. Одно из основных нововведений - сигнальные формы.
ВНИМАНИЕ: Данный функционал помечен как “Экспериментальный”. В нем могут быть ошибки, а API может измениться в будущих релизах. Использовать на production-среде с осторожностью.
Сигнальные формы - это логическое продолжение постепенного ухода от сторонних решений (Zone.js), улучшение контроля за отслеживанием состояния и декларативный подход к управлению состоянием.
Сигнальные формы представлены в виде двух сущностей:
FieldTree - Proxy-объект, хранящий состояние дерева элементов формы в виде сигналов
FieldState - элемент формы, с которым происходит взаимодействие (аналог FormControl).
interface LoginForm { user: string; password: string; } - form (FieldTree<{ user: string, password: string }>) - user(FieldTree<string>) - password(FieldTree<string>) form.user() // FieldState
Преимущества
1. Синхронизация исходных данных без дополнительного контроля
Благодаря прямой поддержке Signal API нам больше не нужно следить за потоком данных "Исходные данные <-> Форма".
В отличие от реактивной формы, в которой исходные данные и данные формы не взаимосвязаны, изменение состояния формы (FieldState) напрямую обновляет данные переданного сигнала.
// Ре export interface LoginFormModel { email: string; password: string; } @Component({ imports: [FormsModule, ReactiveFormsModule], template: ` <form [formGroup]="loginForm"> Email: <input formControlName="email"> Password: <input formControlName="password"> </form> ` }) export class LoginForm implements OnInit, OnChanges { @Input({ required: true }) login!: LoginFormModel; @Output() loginChanged: EventEmitter<LoginFormModel> = new EventEmitter<LoginFormModel>(); loginForm = new FormGroup({ email: new FormControl('', { nonNullable: true }), password: new FormControl('', { nonNullable: true }), }); destroyRef: DestroyRef = inject(DestroyRef); ngOnInit() { this.loginForm.valueChanges .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((value) => this.loginChanged.emit(value as LoginFormModel)); } ngOnChanges(changes: SimpleChanges) { if ('login' in changes) { this.loginForm.patchValue({ ...this.login }, { emitEvent: false }); } } }
// Signal Forms import { Component, effect, model } from '@angular/core'; import { Field, form } from '@angular/forms/signals'; export interface LoginFormModel { email: string; password: string; } @Component({ imports: [Field], template: ` <form> Email: <input [field]="loginForm.email"> Password: <input [field]="loginForm.password"> </form> ` }) export class LoginForm { login = model.required<LoginFormModel>(); loginForm = form(this.login); }
Как видим, для реализации одной и той же логики требуется гораздо меньше строк кода, и нет необходимости вручную перекладывать сущности из "одной коробки в другую".
2. Улучшенная типизация между исходной моделью и формой
Рассмотрим пример потери контекста типизации: работа с элементами формы через метод get.
Метод get возвращает абстрактную сущность AbstractControl, которая является общей для базовых элементов формы (FormControl, FormGroup, FormArray).
// Reactive Forms @Component({ imports: [FormsModule, ReactiveFormsModule], template: ` <form [formGroup]="loginForm"> Email: <input formControlName="email"> Password: <input formControlName="password"> </form> ` }) export class LoginForm implements OnInit, OnChanges { @Input({ required: true }) login!: LoginFormModel; @Output() loginChanged: EventEmitter<LoginFormModel> = new EventEmitter<LoginFormModel>(); loginForm = new FormGroup({ email: new FormControl('', { nonNullable: true }), password: new FormControl('', { nonNullable: true }), additionalInformation: new FormGroup({ firstName: new FormControl(''), lastName: new FormControl('') }) }); destroyRef: DestroyRef = inject(DestroyRef); ngOnInit() { this.loginForm.valueChanges .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((value) => this.loginChanged.emit(value as LoginFormModel)); } ngOnChanges(changes: SimpleChanges) { if ('login' in changes) { this.loginForm.patchValue({ ...this.login }, { emitEvent: false }); const control = this.loginForm.get('additionalInformation') // AbstractControl<{ firstName: string, lastName: string }> | undefined } } }
// Signal Forms export interface LoginFormModel { email: string; password: string; additionalInformation: { firstName: string; lastName: string; } } @Component({ imports: [Field], template: ` <form> Email: <input [field]="loginForm.email"> Password: <input [field]="loginForm.password"> </form> `, }) export class LoginForm { login = model.required<LoginFormModel>(); loginForm = form(this.login); constructor() { effect(() => { console.log('effect', this.loginForm.additionalInformation()); // FieldState<{firstName: string; lastName: string;}> }); this.login.set({ email: 'email', password: 'password', additionalInformation: { firstName: 'First Name', lastName: 'Last Name', }, }); } }
Изменения состояния контрола
Для изменения состояния контрола из формы используются встроенные методы: hidden, readonly и disabled.
Они помогают исключить контрол и дочерние сегменты формы из учета валидации и состояний touched/dirty.
1. hidden
Позволяет создать условие, по которому можно показать/скрыть элемент в html-шаблоне
<!-- Показываем/скрываем элемент, в зависимости от активности свойства --> @if (!loginForm.password().hidden()) { Password: <input [field]="loginForm.password"> } form<{ email: '', password: '' }>({ email: '', password: '' }, (path) => { hidden(path.password); // Поле password будет всегда скрыт }); // Скрытие по условию form<{ email: '', password: '' }>({ email: '', password: '' }, (path) => { hidden(path.password, ({ valueOf }) => !valueOf(path.email)); });
2. disabled
Позволяет создать условие, при котором поле становится недоступным для редактирования.
form<{ email: '', password: '' }>({ email: '', password: '' }, (path) => { disabled(path.password); // Поле password будет всегда недоступным для редактирования }); // Запрет редактирования по условию form<{ email: '', password: '' }>({ email: '', password: '' }, (path) => { disabled(path.password, ({ valueOf }) => !valueOf(path.email)); // Поле password будет недоступным, пока не заполнено поле email });
3. readonly
Позволяет создать условие, при котором поле становится недоступным для редактирования.
В отличие от disabled - для таких полей продолжает работать валидация.
form<{ email: '', password: '' }>({ email: '', password: '' }, (path) => { readonly(path.password); // Поле password будет доступно только для чтения }); // Запрет редактирования по условию form<{ email: '', password: '' }>({ email: '', password: '' }, (path) => { readonly(path.password, ({ valueOf }) => !valueOf(path.email)); // Поле password будет доступно только для чтения, пока не заполнено поле email });
Debounce
Для конфигурации частоты получения обновлений данных в сигнальных формах добавлена встроенная функция debounce.
form<{ email: '', password: '' }>({ email: '', password: '' }, (path) => { debounce(path, 300); // Если форма (одно из полей формы) не подвергалась изменениям в течение 300ms, мы получим обновленные данные (аналог debounceTime в RxJS) }); // Также можно вместо таймера пробросить callback-функцию Debouncer с собственной логикой form<{ email: '', password: '' }>({ email: '', password: '' }, (path) => { debounce(path, ( { valueOf }: RootFieldContext<{ email: '', password: '' }>, abortSignal: AbortSignal ) => { return new Promise<void>((resolve) => { const timeout = setTimeout(() => { resolve(); }, valueOf(path.email).length * 100); // В зависимости от количества символов в поле Email - осуществляется задержка таймера abortSignal.addEventListener('abort', () => { clearTimeout(timeout); resolve(); }); }); }); });
Валидация
Сигнальные формы поддерживают синхронные и ассинхронные валидации.
Для подключения валидации, передадим в функцию form дополнительный опциональный аргумент - callback-функцию.
В callback-функцию передается объект SchemaPath - слепок формы. Через него мы можем указать, для какого поля будем применять валидацию.
form<{ email: '', password: '' }>({ email: '', password: '' }, (path) => { required(path.email); // Поле обязательно для заполнения minLength(path.email, 8); // Минимальная длина поля - 8 символов maxLength(path.email, 24); // Максимальная длина поля - 24 символов pattern(path.email, '^[a-zA-Z0-9@.-_]*$'); // Разрешен ввод латинских символов, цифр и спец символов @.-_ email(path.email); // Поле должно соответствовать почтовой маске - аналог pattern(path.email, /^(?=.{1,254}$)(?=.{1,64}@)[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/) });
Последний аргумент встроенных валидаций позволяет кастомизировать сообщение об ошибке и условие, по которому валидация будет активна.
form<{ email: '', password: '' }>({ email: '', password: '' }, (path) => { required(path.password, { message: 'Поле обязательно для заполнения', when: ({ valueOf }) => valueOf(path.email) // Валидация не будет активна, пока поле "email" не заполнено }); // Поле станет обязательным по условию });
Для создания собственной валидации, воспользуемся функцией validate
form<{ email: '', password: '' }>({ email: '', password: '' }, (path) => { validate(path.password, (ctx) => !ctx.value() ? { kind: 'requiredPassword', message: 'Пароль обязателен' } : undefined) });
Иногда возникает ситуация, когда нам нужно проверить 2+ поля с взаимозависимой логикой.
В сигнальных формах - это решается с помощью validateTree. Она запускает проверку при обнаружении изменения в целевом поле и его дочерних элементах.
form<{ password: '', confirmPassword: '' }>({ password: '', confirmPassword: '' }, (path) => { required(path.password); required(path.confirmPassword); validateTree(path, (ctx) => { return ctx.value().password !== ctx.value().confirmPassword ? { field: ctx.fieldTree.confirmPassword, kind: 'passwordMismatch', message: 'Пароли не совпадают' } }); });
Для асинхронных валидаций используются функции validateAsync и validateHttp.
form<{ email: '', password: '' }>({ email: '', password: '' }, (path) => { validateAsync(path.email, { params: ({ value }) => { // Предопределяющий метод, возвращающий значение, которое передастся в "factory". Также его можно использовать для предварительной фильтрации/валидации if (value() && value().length > 5 && value().includes('@')) { return value(); // Запускаем валидацию, когда поле не пустое, больше 5 символов, и после введения @-ки } return undefined; }, factory: (params) => { return resource({ params, loader: async ({ params }) => { // Ожидаем: HTTP GET возвращает boolean (true - email уникален) return await firstValueFrom(this.http.get<boolean>(`/api/check-unique-email/${params}`)); } }); }, // Валидация успешна, если API вернуло "email уникален" onSuccess: (isUnique) => (!isUnique ? { kind: 'notUniqueEmail', message: 'Email не уникален' } : undefined), // Обработка успешного ответа, если Email не уникален - возвращаем ошибку иначе прошли валидацию onError: () => ({ kind: 'networkError', message: 'Ошибка при выполнении запроса' }), // Показываем ошибку сети debounce: 500, // Устанавливаем ограничение в выполнении запросов для оптимизации }); });
Для асинхронных валидаций по каналу HTTP также можно использовать специализированную функцию validateHttp
form<{ email: '', password: '' }>({ email: '', password: '' }, (path) => { validateHttp(path.email, { // В данном примере request собирается из текущего значения поля email request: ({ valueOf }) => `/api/check-unique-email/${valueOf(path.email)}`, onSuccess: (isUnique) => (!isUnique ? { kind: 'notUniqueEmail', message: 'Email не уникален' } : undefined), // Если Email не уникален - возвращаем ошибку, иначе валидация пройдена onError: () => ({ kind: 'networkError', message: 'Ошибка при выполнении запроса' }), // Показываем ошибку сети }); });
Переиспользование формы
Есть ситуации, когда есть потребность в переиспользовании логики для поля, группы полей и массива.
В сигнальных формах, при создании формы, помимо сигнала для связывания, мы можем передавать схему, созданную извне.
interface Protection { password: string; confirmPassword: string; } interface Location { city: string; street: string; } interface Registration { email: string; protection: Protection; locations: Location[]; wannaSayAboutHobby: boolean; hobby: string; contact: 'phoneNumber' | 'telegram'; phoneNumber?: string; telegram?: string; } /* Схема для примитивного поля */ emailSchema = schema<string>((path) => { required(path); // Поле обязательно для заполнения minLength(path, 8); // Минимальная длина поля - 8 символов maxLength(path, 24); // Максимальная длина поля - 24 символов email(path.email); }); /* Схема для группы полей */ passwordSchema = schema<Protection>((path) => { required(path.password); required(path.confirmPassword); validateTree(path, (ctx) => { return ctx.value().password !== ctx.value().confirmPassword ? { field: ctx.fieldTree.confirmPassword, kind: 'passwordMismatch', message: 'Пароли не совпадают' } }); }); /* Схема, которую подключим для массива данных */ locationSchema = schema<Location>((path) => { required(path.city); required(path.street); minLength(path.city, 3); minLength(path.street, 10); }); protectionModel = signal<Protection>({ password: '', confirmPassword: '' }); registrationModel = signal<Registration>({ email: '', protection: { password: '', confirmPassword: '' }, locations: [] }); registrationForm = form<FormSchema>( this.registrationModel, this.passwordSchema // Схемы можно подключать напрямую к форме ); protectionForm = form<Protection>( this.protectionModel, (path) => { apply(path.email, this.emailSchema); // Использование схемы для одиночного поля apply(path.protection, this.passwordSchema) // Использование схемы для группы полей applyEach(path.location); // Использование схемы для массива полей applyWhen( path.hobby, () => valueOf(path.wannaSayAboutHobby)(), (p) => { required(p); }); // Поле для заполнения хобби станет обязательным, когда поле wannaSayAboutHobby станет - true applyWhenValue( path.contact, (contact) => contact === 'phoneNumber', () => { required(path.phoneNumber); } ); // Номер телефона станет обязательным, когда поле contact станет значением "phoneNumber" } );
Итого
Сигнальные формы в Angular позволяют строить формы через декларативные FieldTree и FieldState, уменьшая количество «ручной» синхронизации между моделью и UI.
Ключевые преимущества:
Более надежная типизация между исходной моделью и полями формы
Удобное управление состоянием контролов (hidden, readonly, disabled), в том числе условное
Встроенные механизмы для debounce и валидации (синхронной и асинхронной)
Переиспользование схем (schema) для одиночных полей, групп и массивов
