angular
March 6

Использование сигналов при работе с формами в Angular

Signal Forms основаны на существующей проверенной функциональности. В шаблоне они используют ngModel для удобства и эффективности. Это позволяет поддерживать синхронизацию шаблона и модули. На уровне TypeScript у Signal Forms аналогичный API с реактивными формами, дополненный некоторыми утилитами, и используют сигналы вместо RxJS. Благодаря схожему API, переход от реактивных форм к Signal Forms должен быть простым при наличии интереса и мотивации.

Начало работы

Для начала работы с Signal Forms достаточно выполнить команду:

npm install ng-signal-forms

FormField<T>

Форма построенная на сигналах создается с использованием методов createFormField и createFormGroup. Самый простой пример - это поле формы FormField, которое создается с помощью createFormField.

import { createFormField } from 'ng-signal-forms';
 
export class AppComponent {
  // implicit type
  name = createFormField('');
 
  // or be explicit about the type
  name = createFormField<string>('');
}


Для создания поля формы используется метод createFormField. Первый аргумент этого метода задает начальное значение поля, что определяет его тип. Для привязки поля к элементу HTML используются атрибуты ngModel и formField.

<div>
  <label for='name'>Name</label>
  <input ngModel [formField]='name' id='name' />
</div>

FormField<T> States

Как упомянуто ранее, атрибут formField является связующим элементом между моделью и элементом HTML. Это обеспечивает синхронизацию значения и состояния, отражая внутреннее состояние формы через соответствующие классы и атрибуты HTML. В данном случае контрол отвечает за установку классов:

  • ng-valid, ng-invalid, and ng-pending
  • ng-untouched и ng-touched
  • ng-pristine и ng-dirty

Signal Forms, следуя конвенции официальных форм Angular, также устанавливают следующие атрибуты:

  • disabled
  • readonly

FormGroup<T>

Для объединения полей в форму (или группу) используется метод createFormGroup. Объект, передаваемый в этот метод, содержит ключи с именами полей и соответствующие значения - сами поля формы.

import { createFormField, createFormGroup } from 'ng-signal-forms';
 
export class AppComponent {
  formModel = createFormGroup({
    name: createFormField(''),
    bibNumber: createFormField<number | undefined>(undefined),
  });
}

Для простых форм без дополнительных опций можно использовать сокращенный способ создания полей. Вместо createFormField можно сразу передать начальное значение.

import { createFormGroup } from 'ng-signal-forms';
 
export class AppComponent {
  formModel = createFormGroup({
    name: '',
    bibNumber: undefined,
  });
}

Для использования formModel в HTML-шаблоне, обратитесь к полям формы через свойство controls модели формы.

<div>
  <label for='name'>Name</label>
  <input ngModel [formField]='formModel.controls.name' id='name' />
</div>
 
<div>
  <label for='bib'>Bib number</label>
  <input type="number" ngModel [formField]='formModel.controls.bibNumber' id='bib' />
</div>

Вложенные FormGroup

Группы форм могут быть вложенными. Для этого используйте метод createFormGroup. В HTML обращайтесь к вложенной группе форм через свойство controls.

import { createFormField, createFormGroup } from 'ng-signal-forms';
 
export class AppComponent {
  formModel = createFormGroup({
    address: createFormGroup({
      street: createFormField(''),
    }),
  });
}
<fieldset>
  <legend>Address</legend>
  <div>
    <label for='street'>Street</label>
    <input ngModel [formField]='formModel.controls.address.controls.street' id='street' />
  </div>
</fieldset>


Метод createFormGroup принимает как объект, так и массив формовых полей (или групп форм). В HTML-шаблоне используйте метод controls для итерации по полям формы и привязки каждого контрола к элементу ввода.

import { createFormField, createFormGroup } from 'ng-signal-forms';
 
export class AppComponent {
  formModel = createFormGroup({
    team: createFormGroup([createFormField(''), createFormField(''), createFormField('')]),
  });
}
<fieldset>
  <legend>Team members</legend>
  @for (member of formModel.controls.team.controls(); track $index) {
    <div>
      <label for='member-{{$index}}'>Team member {{$index}}</label>
      <input ngModel [formField]='member' id='member-{{$index}}' />
    </div>
  }
</fieldset>

FormGroup<T> State

Состояния FormGroup идентичны состояниям полей, за исключением того, что FormGroup не устанавливают классы или атрибуты на HTML-элементе.

Состояние формы вычисляется на основе состояния ее полей. Если хотя бы одно поле изменено (dirty), состояние всей формы так же пометится как измененное. Если хотя бы одно поле былоtouched, состояние группы форм также устанавливается как touched.

Исключение составляет валидация, которая зависит как от группы, так и от валидности ее полей. FormGroup не обладает состояниями disabled или read-only, так как они не применимы к группе форм.

Validators

Форма бесполезна без валидации. Signal Forms предоставляют ряд встроенных валидаторов, которые могут использоваться для проверки полей и форм.

Для регистрации валидатора достаточно передать массив валидаторов в параметры поля. Стандартные валидаторы доступны через экспорт V.

Для отображения сообщения об ошибке пользователю используется метод hasError контрола. Приведенный ниже пример использует touched для отображения ошибки только при взаимодействии пользователя с полем.

import { createFormField, createFormGroup, V } from 'ng-signal-forms';
 
export class AppComponent {
  formModel = createFormGroup({
    name: createFormField('', {
      validators: [V.required()],
    }),
  });
}
<div>
  <label for='name'>Name</label>
  <input ngModel [formField]='formModel.controls.name' id='name' />
  @if (formModel.controls.name.touched()) {
    @if (formModel.controls.name.hasError('required')) {
      <p class="text-[0.8rem] font-medium text-destructive">Name is required.</p>
    }
  }
</div>

Валидационные сообщения

Валидационные сообщения могут быть показаны пользователю, если предоставить сообщение об ошибке валидатору. Это удобнее сделать на этом этапе, чем в шаблоне HTML, а также позволяет повторно использовать сообщения в различных формах.

Для добавления сообщения к валидатору передайте объект с сообщением в сам валидатор. В этом объекте свойство message устанавливается как функция, возвращающая сообщение. Функция принимает конфигурацию валидатора в качестве аргумента, что позволяет создавать динамические сообщения.

Сообщение о валидации можно получить, используя метод errorMessageконтрола формы.

import { createFormField, createFormGroup, V } from 'ng-signal-forms';
 
export class AppComponent {
  formModel = createFormGroup({
    name: createFormField('', {
      validators: [
        V.required(),
        {
          validator: V.minLength(2),
          message: ({ minLength }) => `Name must be at least ${minLength} characters`,
        },
      ],
    }),
  });
}
<div>
  <label for='name'>Name</label>
  <input type='text' id='name' ngModel [formField]='formModel.controls.name' hlmInput />
  @if (formModel.controls.name.touched()) {
    @if (formModel.controls.name.hasError('required')) {
      <p class="text-[0.8rem] font-medium text-destructive">Name is required.</p>
    } @else if (formModel.controls.name.hasError('minLength')) {
      <p class="text-[0.8rem] font-medium text-destructive">
        {{ formModel.controls.name.errorMessage('minLength') }}
      </p>
    }
  }
</div>

Валидация основанная на других полях

В некоторых случаях валидация зависит от значения другого поля. Для этого можно использовать value, чтобы реагировать на изменения другого поля. Так как значение другого поля является сигналом, валидатор автоматически обновится при его изменении.

Простейший способ - создать FormGroup и ссылаться на поля.

На стороне HTML необходимо оставить все без изменений (сообщения о валидации опущены для краткости).

import { createFormField, createFormGroup, V } from 'ng-signal-forms';
 
export class AppComponent {
  formModel = createFormGroup({
    passwords: createFormGroup(() => {
      const password = createFormField('', {
        validators: [V.required()],
      });
      const passwordConfirmation = createFormField('', {
        validators: [V.equalsTo(password.value)],
      });
 
      return {
        password,
        passwordConfirmation,
      };
    }),
  });
}
<div>
    <label for='password'>Password</label>
    <input type='password' ngModel [formField]='formModel.controls.passwords.controls.password' id='password' />
</div>
<div>
    <label for='password-confirmation'>Password Confirmation</label>
    <input type='password' ngModel [formField]='formModel.controls.passwords.controls.passwordConfirmation' id='password-confirmation' />
</div>

Отключение валидаторов

Чтобы отключить валидатор, используйте свойство disabled в конфигурации валидатора.

import { createFormField, createFormGroup, V } from 'ng-signal-forms';
 
export class AppComponent {
  formModel = createFormGroup({
    shipping: createFormGroup(() => {
      const differentFromBilling = createFormField(false);
      return {
        differentFromBilling,
        street: createFormField('', {
          validators: [
            {
              validator: V.required(),
              disable: () => !differentFromBilling.value(),
            },
          ],
        }),
        zip: createFormField('', {
          validators: [
            {
              validator: V.required(),
              disable: () => !differentFromBilling.value(),
            },
          ],
        }),
      };
    }),
  });
}
<section>
  <div>
    <h3>Shipping address</h3>
    <div>
        <label>
          <input type="checkbox" ngModel [formField]='formModel.controls.shipping.controls.differentFromBilling' id='differentFromBilling'  />
          Is different from billing address?
        </label>
    </div>
  </div>
  <div>
    <div>
      <label for='street'>Street</label>
      <input ngModel [formField]='formModel.controls.shipping.controls.street' id='street' />
    </div>
 
    <div>
      <label for='zip'>Zip</label>
      <input ngModel [formField]='formModel.controls.shipping.controls.zip' id='zip' />
    </div>
  </div>
</section>

Дополнительные состояния

Hidden
В примере с отключением валидаторов показано, как можно отключать валидаторы на основе значений других полей. Чтобы обеспечить лучший пользовательский опыт, Signal Forms также учитывают "скрытое" состояние элементов управления, которое можно использовать для скрытия HTML-элементов.

Для скрытия поля формы установите свойство hidden в опции поля формы. Мы можем использовать сигналы для реактивного скрытия поля формы на основе других источников сигналов, например, другого поля формы.

import { createFormField, createFormGroup, V } from 'ng-signal-forms';
 
export class AppComponent {
  formModel = createFormGroup({
    shipping: createFormGroup(() => {
      const differentFromBilling = createFormField(false);
      return {
        differentFromBilling,
        street: createFormField('', {
          hidden: () => !differentFromBilling.value(),
          validators: [
            {
              validator: V.required(),
              disable: () => !differentFromBilling.value(),
            },
          ],
        }),
        zip: createFormField('', {
          hidden: () => !differentFromBilling.value(),
          validators: [
            {
              validator: V.required(),
              disable: () => !differentFromBilling.value(),
            },
          ],
        }),
      };
    }),
  });
}
<section>
  <div>
    <h3>Shipping address</h3>
    <div>
        <label>
          <input type="checkbox" ngModel [formField]='formModel.controls.shipping.controls.differentFromBilling' id='differentFromBilling'  />
          Is different from billing address?
        </label>
    </div>
  </div>
  <div>
    <!-- using the control flow API -->
    @if(!formModel.controls.shipping.controls.street.hidden()){
        <div>
          <label for='street'>Street</label>
          <input ngModel [formField]='formModel.controls.shipping.controls.street' id='street' />
        </div>
    }
 
    <!-- using the hidden attribute-->
    <div [hidden]="formModel.controls.shipping.controls.zip.hidden()">
      <label for='zip'>Zip</label>
      <input ngModel [formField]='formModel.controls.shipping.controls.zip' id='zip' />
    </div>
  </div>
</section>

Disabled & Readonly

Состояния поля FormField<T> автоматически приводят к установке атрибутов disabled и readonly на соответствующем HTML-элементе. Однако способы управления этими состояниями со стороны формы не рассматривались ранее.

Для установки состояний disabled и readonly для поля формы можно использовать свойства disabled и readonly в параметрах контрола. Поскольку HTML-элемент привязан к полю формы, он автоматически отражает текущее состояние поля.

import { createFormField, createFormGroup, V } from 'ng-signal-forms';
 
export class AppComponent {
  readonlyBibNumber = signal(false);
 
  formModel = createFormGroup({
    bibNumber: createFormField<number | undefined>(4706674, {
      readOnly: () => this.readonlyBibNumber(),
    }),
  });
}

Это поведение можно отключить, установив атрибут propagateState в значение false.

<input type='number' ngModel [formField]='formModel.controls.bibNumber' id='bib' [propagateState]="false"  />

Работа со значениями форм

Для получения значения поля или группы можно использовать сигнал value.

Чтение

export class AppComponent {
    formModel = createFormGroup({...})
 
    protected submit(): void {
       console.log(this.formModel.value())
 
      // or from a specific field
      console.log(this.formModel.controls.shipping.controls.street.value())
    }
}

При отправке формы можно также проверить состояние валидации и формы, используя сигнал valid.

export class AppComponent {
    formModel = createFormGroup({...})
 
    protected submit(): void {
      if(this.formModel.valid()){
       console.log(this.formModel.value())
      }
    }
}

Запись

Пока что не так просто обновить FormGroup. Как вариант, можно установить значение отдельного поля. Поскольку мы работаем с сигналами, мы можем использовать методы set и update для обновления значения поля.

export class AppComponent {
    formModel = createFormGroup({...})
 
    protected setValue(): void {
        this.formModel.controls.name.value.set('John Doe');
        this.formModel.controls.bibNumber.value.update(v => v*10);
    }
}

Полезно знать

Использвование существующей ngModel

Поскольку Signal Forms построены на базе существующего NgModel, возможно использовать функции в сочетании с Signal Forms. Например, атрибут ngModelOptions можно использовать для установки стратегии обновления поля формы, в приведенном ниже примере значение поля формы обновляется при потере фокуса.

<input id="username" ngModel [formField]="formModel.controls.name" [ngModelOptions]="{updateOn: 'blur'}"/>

Отладка

Для отладки формы можно использовать effect для регистрации значения и состояния формы при изменении.

export class AppComponent {
  formModel = createFormGroup({...});
  debug = effect(() => {
    console.log('value:', this.formModel.value());
    console.log('valid:', this.formModel.valid());
  })
}

Еще один трюк - использовать json pipe для регистрации значения и состояния формы в HTML-шаблоне.

<pre>
{{
    {
      value: formModel.value(),
      touchState: formModel.touchedState(),
      dirtyState: formModel.dirtyState(),
      valid: formModel.valid(),
      errors: formModel.errorsArray(),
    } | json
}}
</pre>

Заключение

Signal Forms представляет новый метод создания форм в Angular, основанный на существующем NgModel с API, аналогичным ReactiveForms. Он использует сигналы для создания реактивных и гибких форм. Благодаря реактивной природе сигналов, достигается простота создания сложных форм.

Сигналы подходят для этой задачи из-за их реактивного механизма. Форма может реагировать на изменения значений для запуска соответствующих валидаторов.

Ключевая идея Signal Forms заключается в том, что вся логика находится внутри модели формы. Поэтому добавлены дополнительные состояния, такие как hidden, enable/disable и readonly. HTML отвечает только за отображение текущего состояния формы.

Результатом является эффективная, гибкая и реактивная форма.

Пример доступен по ссылке. Больше примеров тут

Это вольный перевод оригинального материала