Перейти к содержанию

Формы с сигналами

📅 30.09.2026

Сигнальные формы хранят состояние в сигналах Angular и сами синхронизируют модель данных с интерфейсом.

Это руководство проводит по основным понятиям сигнальных форм. Как это устроено:

Первая форма

1. Модель формы через signal()

Любая форма начинается с сигнала, в котором лежит модель данных:

1
2
3
4
5
6
7
8
9
interface LoginData {
  email: string;
  password: string;
}

const loginModel = signal<LoginData>({
  email: '',
  password: '',
});

2. Передача модели в form() и получение FieldTree

Затем модель передают в функцию form() и получают дерево полей — структуру, которая повторяет форму модели. К полям обращаются через точку.

И корневой объект формы, и вложенные свойства — это узлы FieldTree:

1
2
3
4
const loginForm = form(loginModel);

loginForm; // is a FieldTree
loginForm.email; // is also a FieldTree

3. Привязка полей HTML директивой [formField]

Дальше поля HTML привязывают к форме директивой [formField]. Между полем и моделью появляется двусторонняя привязка:

1
2
<input type="email" [formField]="loginForm.email" />
<input type="password" [formField]="loginForm.password" />

Ввод пользователя, например набор текста в поле, сам обновляет форму.

Директива [formField] также синхронизирует состояние поля с атрибутами required, disabled и readonly, когда это уместно.

4. Чтение состояния через сигналы FieldTree

Состояние любой части дерева читают вызовом узла FieldTree как функции. Вернётся объект состояния с реактивными сигналами значения, статуса проверки и взаимодействия:

1
2
loginForm(); // Returns state for the whole form
loginForm.email(); // Returns state for the email field

Текущее значение лежит в сигнале value():

1
2
3
<!-- Render values that update automatically as user types -->
<p>Form value: {{ loginForm().value() | json }}</p>
<p>Email: {{ loginForm.email().value() }}</p>
1
2
// Get the current value
const currentEmail = loginForm.email().value();

5. Обновление значений через set()

Значение можно задать из кода методом value.set() на любом узле. Так обновляются и FieldTree, и сигнал модели под ним:

1
2
// Update the value programmatically
loginForm.email().value.set('[email protected]');

В итоге и значение поля, и сигнал модели обновляются сами:

1
2
// The model signal is also updated
console.log(loginModel().email); // '[email protected]'

Полный пример

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import {Component, signal} from '@angular/core';
import {form, FormField} from '@angular/forms/signals';

interface LoginData {
  email: string;
  password: string;
}

@Component({
  selector: 'app-root',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [FormField],
})
export class App {
  loginModel = signal<LoginData>({
    email: '',
    password: '',
  });

  loginForm = form(this.loginModel);

  onSubmit(event: Event) {
    event.preventDefault();

    // Perform login logic here
    const credentials = this.loginModel();
    console.log('Logging in with:', credentials);

    // e.g., await this.authService.login(credentials);
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<form (submit)="onSubmit($event)">
  <label>
    Email:
    <input type="email" [formField]="loginForm.email" />
  </label>

  <label>
    Password:
    <input type="password" [formField]="loginForm.password" />
  </label>

  <p>Hello {{ loginForm.email().value() }}!</p>
  <p>Password length: {{ loginForm.password().value().length }}</p>

  <button type="submit">Log In</button>
</form>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
form {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  max-width: 400px;
  padding: 1rem;
  font-family: Inter, system-ui, -apple-system, sans-serif;
}

label {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}

input {
  padding: 0.5rem;
  border: 1px solid #ccc;
  border-radius: 4px;
  font-size: 1rem;
  font-family: inherit;
}

p {
  margin: 0.5rem 0;
  color: #666;
}

Базовое использование

Директива [formField] работает со всеми стандартными типами полей HTML. Ниже самые частые схемы.

Текстовые поля

Текстовые поля работают с разными атрибутами type и с textarea:

1
2
3
<!-- Text and email -->
<input type="text" [formField]="form.name" />
<input type="email" [formField]="form.email" />

Числа

Числовые поля сами переводят строку в число и обратно:

1
2
<!-- Number - automatically converts to number type -->
<input type="number" [formField]="form.age" />

Дата и время

Поля даты хранят значение строкой YYYY-MM-DD, поля времени — в формате HH:mm:

1
2
3
<!-- Date and time - stores as ISO format strings -->
<input type="date" [formField]="form.eventDate" />
<input type="time" [formField]="form.eventTime" />

Чтобы превратить строку даты в объект Date, передайте значение поля в Date():

1
const dateObject = new Date(form.eventDate().value());

Многострочный текст

textarea работает так же, как текстовое поле:

1
2
<!-- Textarea -->
<textarea [formField]="form.message" rows="4"></textarea>

Флажки

Флажки привязываются к логическим значениям:

1
2
3
4
5
<!-- Single checkbox -->
<label>
  <input type="checkbox" [formField]="form.agreeToTerms" />
  I agree to the terms
</label>

Несколько флажков

Для нескольких вариантов заведите отдельное логическое поле formField на каждый:

1
2
3
4
5
6
7
8
<label>
  <input type="checkbox" [formField]="form.emailNotifications" />
  Email notifications
</label>
<label>
  <input type="checkbox" [formField]="form.smsNotifications" />
  SMS notifications
</label>

Переключатели

Переключатели устроены похоже на флажки. Если у них один и тот же [formField], сигнальные формы сами проставят всем один атрибут name:

1
2
3
4
5
6
7
8
<label>
  <input type="radio" value="free" [formField]="form.plan" />
  Free
</label>
<label>
  <input type="radio" value="premium" [formField]="form.plan" />
  Premium
</label>

Когда пользователь выбирает переключатель, formField формы сохраняет значение из атрибута value этого переключателя. Например, выбор «Premium» записывает в form.plan().value() значение "premium".

Выпадающие списки

Элемент select работает и со статическими, и с динамическими вариантами:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<!-- Static options -->
<select [formField]="form.country">
  <option value="">Select a country</option>
  <option value="us">United States</option>
  <option value="ca">Canada</option>
</select>

<!-- Dynamic options with @for -->
<select [formField]="form.productId">
  <option value="">Select a product</option>
  @for (product of products; track product.id) {
    <option [value]="product.id">{{ product.name }}</option>
  }
</select>

Множественный выбор (<select multiple>) директива [formField] пока не поддерживает.

Проверка и состояние

В сигнальных формах есть встроенные валидаторы для полей. Чтобы включить проверку, передайте функцию схемы вторым аргументом form():

1
2
3
4
5
const loginForm = form(loginModel, (schemaPath) => {
  debounce(schemaPath.email, 500);
  required(schemaPath.email);
  email(schemaPath.email);
});

Функция схемы получает параметр пути схемы: через него задают правила проверки для полей.

Частые валидаторы:

  • required() — поле обязательно должно иметь значение
  • email() — проверяет формат адреса электронной почты
  • min() / max() — проверяет диапазон числа
  • minLength() / maxLength() — проверяет длину строки или коллекции
  • pattern() — проверяет значение по регулярному выражению

Текст ошибки задают объектом параметров вторым аргументом валидатора:

1
2
required(schemaPath.email, {message: 'Email is required'});
email(schemaPath.email, {message: 'Please enter a valid email address'});

Каждый узел FieldTree отдаёт состояние проверки и взаимодействия реактивными сигналами.

Сигналы состояния FieldTree

У каждого узла дерева, включая корневой объект формы, один и тот же набор сигналов. Поскольку каждый узел — это FieldTree, API проверки и отслеживания взаимодействия одинаков на любом уровне.

Состояние Описание
valid() Возвращает true, если узел проходит все правила проверки
invalid() Возвращает true, если есть ошибки проверки
pending() Возвращает true, если идёт асинхронная проверка
touched() Возвращает true, если пользователь фокусировал поле или любое дочернее поле и уводил с него фокус
dirty() Возвращает true, если пользователь изменил значение
disabled() Возвращает true, если узел отключён
readonly() Возвращает true, если узел только для чтения
errors() Возвращает массив ошибок проверки со свойствами kind и message

Полный пример

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import {Component, signal} from '@angular/core';
import {email, form, FormField, required} from '@angular/forms/signals';

interface LoginData {
  email: string;
  password: string;
}

@Component({
  selector: 'app-root',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [FormField],
})
export class App {
  loginModel = signal<LoginData>({
    email: '',
    password: '',
  });

  loginForm = form(this.loginModel, (schemaPath) => {
    required(schemaPath.email, {message: 'Email is required'});
    email(schemaPath.email, {message: 'Enter a valid email address'});

    required(schemaPath.password, {message: 'Password is required'});
  });

  onSubmit(event: Event) {
    event.preventDefault();
    // Perform login logic here
    const credentials = this.loginModel();
    console.log('Logging in with:', credentials);
    // e.g., await this.authService.login(credentials);
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<form (submit)="onSubmit($event)">
  <div>
    <label>
      Email:
      <input type="email" [formField]="loginForm.email" />
    </label>

    @if (loginForm.email().touched() && loginForm.email().invalid()) {
      <ul class="error-list">
        @for (error of loginForm.email().errors(); track error) {
          <li>{{ error.message }}</li>
        }
      </ul>
    }
  </div>

  <div>
    <label>
      Password:
      <input type="password" [formField]="loginForm.password" />
    </label>

    @if (loginForm.password().touched() && loginForm.password().invalid()) {
      <div class="error-list">
        @for (error of loginForm.password().errors(); track error) {
          <p>{{ error.message }}</p>
        }
      </div>
    }
  </div>

  <button type="submit">Log In</button>
</form>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
form {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  max-width: 400px;
  padding: 1rem;
  font-family:
    Inter,
    system-ui,
    -apple-system,
    sans-serif;
}

div {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}

label {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  font-weight: 500;
}

input {
  padding: 0.5rem;
  border: 1px solid #ccc;
  border-radius: 4px;
  font-size: 1rem;
  font-family: inherit;
}

input:focus {
  outline: none;
  border-color: #4285f4;
}

button {
  padding: 0.75rem 1.5rem;
  background-color: #4285f4;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 1rem;
  font-family: inherit;
  cursor: pointer;
  transition: background-color 0.2s;
}

button:hover {
  background-color: #357ae8;
}

button:active {
  background-color: #2a65c8;
}

.error-list {
  color: red;
  font-size: 0.875rem;
  margin: 0.25rem 0 0 0;
  padding-left: 0;
  list-style-position: inside;
}

.error-list p {
  margin: 0;
}

Следующие шаги

Подробнее о сигнальных формах и о том, как они устроены, — в подробных руководствах:


Источник: https://angular.dev/essentials/signal-forms

Комментарии