@nishitaku さんの記事や @mashirojs さんの記事 と内容が被ってしまい恐縮ですが、今更(といってもv21からの実験要素なので実装も最近ですが)Signal Formsを見つけて気になったので便乗で執筆させていただきます。
Signal Formsは実験的機能です。APIが将来のリリースで変更になることがあります。そのリスクを理解できない場合、本番環境で実験的なAPIを利用することは避けてください。
(原文和訳)
作るもの
必須チェック付きの入力1個とボタン1つの超簡単なフォーム(笑)
Signal Forms以前に我々はどういう実装をしていたか
入力して必須チェックして送信する例で見てみる
テンプレート駆動の例
import { Component, computed, inject, signal, viewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-template-binding',
imports: [FormsModule],
template: `
<form #templateForm="ngForm">
<input name="foo" [(ngModel)]="foo" #fooInput="ngModel" required>
<div [style.color]="'#f00'">
@if (fooInput.touched && fooInput.invalid) {
{{ `必須項目です` }}
} @else {
}
</div>
<button [disabled]="templateForm.invalid" (click)="submit()">送信</button>
</form>
`,
})
export class TemplateBinding {
private templateForm = viewChild.required(NgForm);
public foo = signal('');
private formModel = computed(() => {
return {
foo: this.foo(),
} satisfies PostFooRequestBody;
});
public submit() {
if (this.templateForm().valid) {
console.log(`本番では次のデータを送信します: ${JSON.stringify(this.formModel())}`);
}
}
}
interface PostFooRequestBody {
foo: string;
}
テンプレートに必須チェックやフォームの宣言が固まっていて柔軟性に欠ける。
わざわざformModelをcomputedで実装しているのはNgForm.valueの型が不定なため。
リアクティブフォームを使用する例
import { Component } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-reactive-form',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="reactiveForm">
<input type="text" formControlName="foo" />
<div [style.color]="'#f00'">
@if (fooControl.touched && fooControl.invalid) {
{{ `必須項目です` }}
} @else {
}
</div>
<button [disabled]="!reactiveForm.valid" (click)="submit()">送信</button>
</form>
`,
})
export class FormcontrolPage {
public readonly fooControl = new FormControl('', Validators.required);
public readonly reactiveForm = new FormGroup({
foo: this.fooControl,
});
public submit() {
if (this.reactiveForm.valid) {
const body = <PostFooRequestBody>this.reactiveForm.value;
console.log(`本番では次のデータを送信します: ${JSON.stringify(body)}`);
}
}
}
interface PostFooRequestBody {
foo: string;
}
Formの定義をスクリプト側に寄せられたのは嬉しいが、コントロール中心なのでフォームの入力をモデルとして纏めるときに課題が出てくる。
FormGroupの構造が動的に変更可能なために、 FormGroup.valueはPartial型でやってくる のでこのようなstringそのまま送信するだけのフォームでも型変換が必要になる。
こんな感じに強制型変換するか、デッドロジック覚悟で型ガードロジックを組むかである。
また、キー名'foo'をRequestBody->FormControl(FormGroupに紐づけるキー)->HTMLのformControlNameの3か所に記述しているが、強制型変換にしろ型ガードにしろ 元がPartialなのでtypoなどで不整合が起きてもエラーと判定してくれない。
const定数にすればいいのだが、interfaceのキー名をconst定数から引っ張ってきて可読性はどうなのかとか、そのconst定数の定義箇所と命名どうするの問題になる。
ちなみに、今回は省略しているが変更イベントがObservableなので、イベントを購読したくなると都度pipe(takeUntilDestroyed())を挟んだり、computedみたいなことしたくなると発火元を逐一イベント購読することになる。
要するに
今までのフォームってコントロールベースな設計思想でTypeScriptの型システムと相性悪いからモデルベースでフォーム制御できるようにしようぜ!
本命のSignal Formsを使ってみる
既存のリアクティブフォームとは異なり、モデルを基にコントロールを後から定義していくスタイル(な気がする)
import { Component, effect, inject, signal, untracked } from '@angular/core';
import { form, Field, required } from '@angular/forms/signals';
@Component({
selector: 'app-signal-forms',
imports: [Field],
template: `
<form>
<!-- フィールド"foo"やfoo配下の各プロパティは存在や型安全性が保障されている -->
<input type="text" [field]="foo" />
<div [style.color]="'#f00'">
@if (foo().touched() && foo().invalid()) {
<!-- foo配下にエラーメッセージ自体を格納できるので、再利用性が高い -->
@for (err of foo().errors(); track err) {
{{ err.message }}
}
} @else {
}
</div>
<button [disabled]="!signalForm().valid()" (click)="submit()">送信</button>
</form>
`,
})
export class SignalformPage {
private formModel = signal<PostFooRequestBody>({
foo: '',
});
// form()関数でフォーム(Signal Forms)を生成します
public signalForm = form(this.formModel, (schemaPath) => {
// バリデーションやエラーメッセージを定義できます
required(schemaPath.foo, { message: '必須項目です' });
});
// 入力欄コンポーネントの再利用を想定して、signalForm.fooをfooとして受け取ったのと同じように実装してみます
public foo = signalForm.foo;
constructor() {
// 生成したフォームやその下のコントロールや各プロパティがsignalで扱えるので、effectやcomputedでの連動も容易です
effect(() => {
this.signalForm();
console.log('formが変更されました');
});
effect(() => {
this.signalForm.foo();
console.log('fooが変更されました');
});
effect(() => {
const valid = this.signalForm().valid();
console.log(`チェックエラーの有無: ${valid ? '無' : '有'}`);
});
}
public submit() {
if (this.signalForm().valid()) {
console.log(`本番では次のデータを送信します: ${JSON.stringify(this.formModel())}`);
}
}
}
interface PostFooRequestBody {
foo: string;
}
Signal Forms の良いところ
formに関連した実装にはMapped Typesが使用されているので、フォームやバリデーションを定義する際のschemaPathにはちゃんと型推論が効いてフィールド(foo)の存在が保証されている。なのでtypoしたら静的解析でエラー出してくれる。
バリデーションするだけではなくエラーメッセージも定義、一元管理できるので、入力項目はフィールドさえ受け取れればそれ以外の不要な関心に注意を払う必要もない。
フォーム以外からモデルに変更を加えたければ、モデルのsignalを更新すればフォームにも反映されるのでAPIのレスポンス待ちなどで遅延させたい場合も問題なく実装できる。
などなど。公式ドキュメント
要約
Signal FormsがAngularのシグナル実装の集大成、今後シグナルを導入する最大の目的になりそうな強力な機能だと感じました。
バリデーション条件やエラーメッセージもフォーム側で一元管理できる上に実装が平易なため、Angularの再利用思想との相性も非常によくできているように思えます。
ざっくりと書いてしまいましたが、今までのフォームにあったこれじゃない感がかなり軽減されていたので今後とにかく使ってみたくなる実装でした!
