サンプルコードで身につける: シグナルフォーム — form() でフォームもシグナルに
解説で学んだ概念を、5つの具体例で確認します。素朴な例から実務寄りの例へと進みます。
1最小のシグナルフォーム — モデルと form()
シグナルフォームの出発点は「フォームの中身をシグナルのモデルとして持つ」ことです。signal({ email: '' }) でモデルを作り form() に渡すと、各項目に対応したフィールドのツリーができます。値の実体はあくまでただのシグナルなので、model() でいつでも現在の中身を読み取れます。
import { Component, signal } from '@angular/core';
import { form } from '@angular/forms/signals';
@Component({
selector: 'app-minimal-signal-form',
template: '<p>現在の入力: {{ model().email }}</p>',
})
export class MinimalSignalFormComponent {
// フォームの中身はただのシグナルのモデル
model = signal({ email: '' });
// form() に渡すと、各項目に対応したフィールドのツリーができる
loginForm = form(this.model);
// 値の実体はモデルのシグナルなので、model() で読み取れる
}
2[formField] で入力欄とフィールドをつなぐ
テンプレートでは、入力欄に [formField]="loginForm.email" を付けてフィールドと双方向に同期します。ユーザーが入力すればモデルのシグナルが更新され、モデルを書き換えれば入力欄にも反映されます。リアクティブフォームの formControlName にあたる役割を、シグナル時代はこのディレクティブが担います。
import { Component, signal } from '@angular/core';
import { form, FormField } from '@angular/forms/signals';
@Component({
selector: 'app-profile-signal-form',
imports: [FormField],
// [formField] で入力欄とフィールドを双方向に同期する
template:
'<input [formField]="profileForm.name" placeholder="名前">' +
'<input type="email" [formField]="profileForm.email" placeholder="メール">' +
'<p>入力中: {{ model().name }} / {{ model().email }}</p>',
})
export class ProfileSignalFormComponent {
model = signal({ name: '', email: '' });
profileForm = form(this.model);
}
3スキーマ関数で required と email の検証を宣言する
検証は form() の第2引数に渡すスキーマ関数の中で宣言します。required(path.email, { message: '...' }) のように「どの項目にどの検証を効かせるか」を書くと、値が変わるたびに自動で再判定されます。検証ルールがフォーム定義の1か所にまとまるのが、この方式の読みやすさです。
import { Component, signal } from '@angular/core';
import { form, FormField, required, email } from '@angular/forms/signals';
@Component({
selector: 'app-signup-signal-form',
imports: [FormField],
template:
'<input type="email" [formField]="signupForm.email">' +
'<input type="password" [formField]="signupForm.password">' +
'<button [disabled]="signupForm().invalid()">登録</button>',
})
export class SignupSignalFormComponent {
model = signal({ email: '', password: '' });
// スキーマ関数の中で、どの項目にどの検証を効かせるかを宣言する
signupForm = form(this.model, (path) => {
required(path.email, { message: 'メールは必須です' });
email(path.email, { message: 'メール形式で入力してください' });
required(path.password, { message: 'パスワードは必須です' });
});
}
4touched() と errors() でエラーを表示する
各フィールドの状態はすべてシグナルとして読み取れます。loginForm.email().touched() と invalid() を組み合わせて「触れたあとに無効ならエラー表示」とし、errors() を @for で回してメッセージを出します。リアクティブフォームで学んだ出し分けの考え方が、そのままシグナルの読み取りに置き換わった形です。
import { Component, signal } from '@angular/core';
import { form, FormField, required, email } from '@angular/forms/signals';
@Component({
selector: 'app-login-signal-form',
imports: [FormField],
// touched() && invalid() のときだけ errors() のメッセージを表示する
template:
'<input type="email" [formField]="loginForm.email">' +
'@if (loginForm.email().touched() && loginForm.email().invalid()) {' +
' @for (e of loginForm.email().errors(); track e) { <p>{{ e.message }}</p> }' +
'}',
})
export class LoginSignalFormComponent {
model = signal({ email: '' });
loginForm = form(this.model, (path) => {
required(path.email, { message: 'メールは必須です' });
email(path.email, { message: 'メール形式で入力してください' });
});
}
5computed と組み合わせる — 全部シグナルの利点
シグナルフォームでは値も妥当性もすべてシグナルなので、computed とそのまま組み合わせられます。この例ではモデルの値から確認用の表示文を、フォーム全体の invalid() から送信可否を、どちらも computed で導出しています。リアクティブフォームでは購読が必要だった連動が、宣言的に書けるのが利点です。
import { Component, computed, signal } from '@angular/core';
import { form, FormField, required } from '@angular/forms/signals';
@Component({
selector: 'app-preview-signal-form',
imports: [FormField],
template:
'<input [formField]="nameForm.name">' +
'<p>{{ preview() }}</p>' +
'<button [disabled]="!canSubmit()">送信</button>',
})
export class PreviewSignalFormComponent {
model = signal({ name: '' });
nameForm = form(this.model, (path) => {
required(path.name, { message: '名前は必須です' });
});
// モデルの値から表示文を導出する。値が変われば自動で更新される
preview = computed(() => `こんにちは、${this.model().name || '名無し'}さん`);
// フォーム全体の妥当性もシグナルなので computed に組み込める
canSubmit = computed(() => !this.nameForm().invalid());
}