19 ноября 2025 года команда Angular выпустила 21 версию фреймворка. Одно из основных нововведений - сигнальные формы.

ВНИМАНИЕ: Данный функционал помечен как “Экспериментальный”. В нем могут быть ошибки, а API может измениться в будущих релизах. Использовать на production-среде с осторожностью.

Сигнальные формы - это логическое продолжение постепенного ухода от сторонних решений (Zone.js), улучшение контроля за отслеживанием состояния и декларативный подход к управлению состоянием.

Сигнальные формы представлены в виде двух сущностей:

  1. FieldTree - Proxy-объект, хранящий состояние дерева элементов формы в виде сигналов

  2. 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) для одиночных полей, групп и массивов