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) для одиночных полей, групп и массивов
Автор: kuznetsov-nikolay-dev
