Signal Forms(プロトタイプ)のチュートリアル
Signal Forms
現在開発中のAngular次世代フォームAPI。ここ数年、AngularのAPIは既存の仕組みから signalベースのリアクティブAPIへと移行しつつある。
従来のフォームAPIには次の2種類が存在する。
- テンプレート駆動フォーム
- リアクティブフォーム
そこに 第三の選択肢として加わるのがSignal Forms。AngularはsignalベースのAPIを主軸に据える方向へ進んでおり、フォーム実装のファーストチョイスになる可能性が高い。
Signal Formsは現在 PoC(Proof of Concept)段階にあり、以下のブランチでプロトタイプが開発されている。
概要や設計思想についてはsignal-forms.mdに詳しくまとめられており、実際のフォーム実装に関してはチュートリアルが用意されている。
この記事では、そのチュートリアルをベースに、Signal Formsの基本的なAPIの使い方を整理して紹介する。
動作確認バージョン
Angular CLI: 20.2.0
Node: 22.14.0
Package Manager: npm 10.5.0
OS: darwin arm64
Angular: 20.2.1
... cdk, common, compiler, compiler-cli, core, forms, material
... platform-browser, router
Package Version
------------------------------------
@angular-devkit/architect 0.2002.0
@angular-devkit/core 20.2.0
@angular-devkit/schematics 20.2.0
@angular/build 20.2.0
@angular/cli 20.2.0
@schematics/angular 20.2.0
rxjs 7.8.2
typescript 5.9.2
なお、チュートリアルに倣って、この記事ではAngular MaterialをUIコンポーネントに使用している。
実装
インターフェースの定義
まず、フォームのインターフェースを定義する。
interface Friend {
name: string;
email: string;
}
interface Feedback {
name: string;
email: string;
password: string;
confirmationPassword: string;
recommendToFriends: boolean;
friends: Friend[];
}
signalデータモデルの追加
Feedback
インターフェースのsignalデータモデルをComponentに実装し、状態を保持する。
@Component()
export class AppComponent {
readonly data = signal<Feedback>({
name: '',
email: '',
password: '',
confirmationPassword: '',
recommendToFriends: false,
friends: [],
});
}
フォームインスタンスの作成
Signal Formsはフォーム内部に状態を保持しない。
代わりに、signalで定義したデータモデルを唯一の情報源(Source of Truth) として扱い、そこからform()
によってフォームインスタンスをリアクティブに生成する。
@Component()
export class AppComponent {
readonly data = signal<Feedback>({/*...*/});
readonly form = form(this.data)
}
ここが従来のリアクティブフォームとの決定的な違い。
リアクティブフォームでは、FormGroup
を唯一の情報源(SSOT: single source of truth)として定義し、getRawValue()
などを通じてデータを取得し、APIに送信する設計になっていた。そのため、FormGroup
の状態を監視する必要があり、フォームが複雑になるほどコード量が増え、煩雑さが問題になっていた。
Signal Formsはその逆の発想を取る。
signalデータモデルをSSOTとして定義し、フォームインスタンスはそれをもとにリアクティブに変化する仕組み。これにより、signalの強力な状態監視をそのまま活用でき、コードの複雑さを大幅に抑えられる。
テンプレートにバインドする
フォームインスタンスは、[control]
ディレクティブでテンプレートにバインドする。リアクティブフォームにおけるformControl
と同じイメージ。
<mat-form-field>
<mat-label>Name</mat-label>
<input [control]="form.name" matInput>
</mat-form-field>
バリデーション
組み込みバリデーション
name
に組み込みの必須バリデーションrequired
を追加する。
@Component()
export class AppComponent {
readonly form = form(this.data, (path) => {
required(path.name);
});
}
form.name().valid()
やform.name().errors()
でname
フォームの状態が取得できる。例えば、テンプレートでエラーを表示したい場合はこうする。
<mat-form-field>
<mat-label>Name</mat-label>
<input [control]="form.name" matInput>
@if (!form.name().valid()) {
<mat-error>
@if (form.name().errors()[0].kind === 'required') {
<span>This field is required</span>
}
</mat-error>
}
</mat-form-field>
カスタムバリデーション
email
に必須バリデーションと、カスタムバリデーションを追加する。
@Component()
export class AppComponent {
readonly form = form(this.data, (path) => {
required(path.email);
validate(path.email, ({value}) => {
return value().includes('@') ?
undefined :
{kind: 'email'};
})
// 切り出した場合
// validate(path.email, emailValidator);
});
}
export const emailValidator: FieldValidator<string> = ({ value }) => {
return value().includes('@') ? undefined : { kind: 'email' };
};
クロスバリデーション
password
とconfirmationPassword
に必須バリデーションを追加。
さらにconfirmationPassword
にpassword
との一致をチェックするクロスバリデーションを追加する。
valueOf
をvalidate
関数内で呼び出すことで、特定のフォームフィールドの値を取得できる。
@Component()
export class AppComponent {
readonly form = form(this.data, (path) => {
required(path.password);
required(path.confirmationPassword);
validate(path.confirmationPassword, ({value, valueOf}) => {
return value() === valueOf(path.password)
? undefined
: {kind: 'confirmationPassword'};
});
// 切り出した場合
// validate(path.confirmationPassword, confirmationPasswordValidator(path));
});
}
export function confirmationPasswordValidator(
path: FieldPath<{ password: string }>
): FieldValidator<string> {
return ({ value, valueOf }) => {
return value() === valueOf(path.password)
? undefined
: { kind: 'confirmationPassword' };
};
}
disabled
password
が入力されるまで、confirmationPassword
のフォームをdisabled
にする。
@Component()
export class AppComponent {
readonly form = form(this.data, (path) => {
disabled(path.confirmationPassword, ({ valueOf }) => {
return valueOf(path.password) === '';
});
});
}
これだけで、テンプレート側は[control]
ディレクティブが自動で処理してくれる。
配列フォーム
リアクティブフォームだとFormArray
を使うなどして複雑になりがちだったユースケース。
インターフェースの確認
interface Friend {
name: string;
email: string;
}
interface Feedback {
/* ... */
friends: Friend[];
}
スキーマを定義
friend
用の再利用可能なスキーマを定義する。
export const friendSchema = schema<Friend>((friend) => {
required(friend.name);
required(friend.email);
validate(friend.email, emailValidator);
});
このように、バリデーションや無効化などのロジックを、TypeScriptで宣言的に定義できることが、Signal Formsの大きな特徴のひとつ。
フォームの構造を定義する段階で、状態を決定するルールや条件(スキーマ)をあらかじめ指定できる。
従来のリアクティブフォームでは、フォームインスタンスを作成するときにロジックを組み込む必要があり、動的にフォームを追加するケースではコードが煩雑になりやすかった。
Signal Formsはこの問題を根本から解消し、スキーマによって 再利用性が高く、明快なフォーム定義を可能にしている。
FriendComponent
の実装
@Component()
export class FriendComponent {
friend = input.required<Field<Friend>>();
}
@let friend = this.friend();
<mat-form-field appearance="outline">
<mat-label>Name</mat-label>
<input [control]="friend.name" matInput>
@if(!friend.name().valid()){
<mat-error>{{ friend.name().errors()[0].kind}}</mat-error>
}
</mat-form-field >
<mat-form-field appearance="outline">
<mat-label>Email</mat-label>
<input [control]="friend.email" matInput>
@if(!friend.email().valid()){
<mat-error>{{ friend.email().errors()[0].kind}}</mat-error>
}
</mat-form-field>
friendSchema
の適用
親コンポーネントでfriendSchema
を適用し、配列に追加する処理を実装する。
@Component()
export class AppComponent {
readonly form = form(this.data, (path) => {
applyEach(path.friends, friendSchema);
});
}
FriendComponent
の利用
@for (friend of form.friends; track friend) {
<app-friend [friend]="friend" />
}
配列の追加
@Component()
export class AppComponent {
addFriend() {
this.form.friends().value.update((f) => [...f, { name: '', email: '' }]);
// 以下でも可
// this.data.update((d) => ({ ...d, friends: [...d.friends, { name: '', email: '' }] }));
}
}
<button type="button" matButton="outlined" (click)="addFriend()">Add Friend</button>
hidden
friends
はrecommendToFriends
のチェックボックスがtrue
の場合のみ表示する。
<label>
<mat-checkbox [control]="form.recommendToFriends"> Recommend to friends </mat-checkbox>
</label>
@if (form.recommendToFriends().value()) {
@for (friend of form.friends; track friend) {
<app-friend [friend]="friend"></app-friend>
}
<button type="button" matButton="outlined" (click)="addFriend()">Add Friend</button>
}
これだけだと、invalidなfriend
が存在する状態で、recommendToFriends
のチェックボックスをfalse
にしてfriends
を非表示にした場合、UI上はfriends
が表示されていないにも関わらず、フォームはinvalidなままになってしまう。
これについては、以下のように、recommendToFriends
がfalse
の場合に、friends
をhiddenにすることで、解決できる。
@Component()
export class AppComponent {
readonly form = form(this.data, (path) => {
hidden(path.friends, ({ valueOf }) => {
return valueOf(path.recommendToFriends) === false;
});
});
}
あるいは、applyWhen
を使って、特定の条件の場合にのみ、バリデーションを有効化することもできる。
@Component()
export class AppComponent {
readonly form = form(this.data, (path) => {
applyWhen(
path,
({ value }) => value().recommendToFriends,
(pathWhenTrue) => {
applyEach(pathWhenTrue.friends, friendSchema);
}
);
});
}
完成したもの
まとめ
Signal Forms(プロトタイプ)を紹介した。
リアクティブフォームで肥大化したコードや状態管理に苦しんだ経験がある開発者ほど、その設計思想とシンプルさに衝撃を受けるはずだ。フォームの状態をsignalに集約することで、バリデーション、配列フォーム、条件付き制御といった処理を直感的に記述でき、従来の煩雑さから解放される。
とくに、リアクティブフォームとの比較で際立つのは次の2点。
- signalデータモデルをSSOTとしてフォームインスタンスを生成する設計
- バリデーションや制御ロジックをTypeScriptで宣言的に記述できる仕組み
まだPoC段階であり、仕様が変わる可能性はある。それでも、すでに十分な完成度を備えており、Angularが進むべき方向性をはっきりと示している。
リアクティブフォームを置き換える日が近づいている。正式リリースが待ち遠しい。
Discussion