# シグナルフォーム シグナルフォームは、対象のAngularバージョンがサポートしている場合、新しいフォームに推奨されます。Angularシグナルを使用して、リアクティブかつ型安全なモデル駆動型のフォーム状態管理を提供します。 シグナルフォームを使用する場合、フィールドの値や型に `null` を使用しないでください。 ## インポート `@angular/forms/signals` から以下をインポートできます: ```ts import { form, FormField, submit, // フィールド状態のルール disabled, hidden, readonly, debounce, // スキーマヘルパー applyWhen, applyEach, schema, // カスタムバリデーション validate, validateHttp, validateStandardSchema, // メタデータ metadata, } from '@angular/forms/signals'; ``` ## フォームの作成 シグナルモデルとともに `form()` 関数を使用します。フォームの構造はモデルから直接導出されます。 ```ts import {Component, signal} from '@angular/core'; import {form, FormField} from '@angular/forms/signals'; @Component({ // ... imports: [FormField], }) export class Example { // 1. 初期値でモデルを定義する(undefinedは避ける) userModel = signal({ name: '', // 重要: 初期値にnullやundefinedは絶対に使用しない email: '', age: 0, // 数値には0を使用し、nullは使用しない address: { street: '', city: '', }, hobbies: [] as string[], // 配列には[]を使用し、nullは使用しない }); // 誤り - このようにしてはいけない: // badModel = signal({ // name: null, // エラー: '' を使用すること // age: null, // エラー: 0 を使用すること // items: null // エラー: [] を使用すること // }); // 2. フォームを作成する userForm = form(this.userModel); } ``` ## バリデーション バリデーターを `@angular/forms/signals` からインポートします。 ```ts import {required, email, min, max, minLength, maxLength, pattern} from '@angular/forms/signals'; ``` `form()` に渡すスキーマ関数内で使用します: ```ts userForm = form(this.userModel, (schemaPath) => { // 必須 required(schemaPath.name, {message: 'Name is required'}); // 条件付き必須 required(schemaPath.name, { when({valueOf}) { return valueOf(schemaPath.age) > 10; }, }); // when は required にのみ使用可能 // このようにしてはいけない: pattern(p.name, /xxx/, {when /* ERROR */) // メールアドレス email(schemaPath.email, {message: 'Invalid email'}); // 数値の最小値/最大値 min(schemaPath.age, 18); max(schemaPath.age, 100); // 文字列/配列の最小長/最大長 minLength(schemaPath.password, 8); maxLength(schemaPath.description, 500); // パターン(正規表現) pattern(schemaPath.zipCode, /^\d{5}$/); }); ``` ## FieldState と FormField: 親要素の要件 **FormField**(構造)と **FieldState**(実際のデータ/シグナル)の違いを理解することが重要です。 **ルール**: フィールドを関数として**呼び出す**ことで、そのステートシグナル(valid、touched、dirty、hidden など)にアクセスできます。 ```ts // f は FormField(構造的) const f = form(signal({cat: {name: 'pirojok-the-cat', age: 5}})); f.cat.name; // FormField: ここからフラグは取得できない! f.cat.name.touched(); // エラー: touched() は FormField に存在しない f.cat.name(); // FieldState: 呼び出すことでシグナルへのアクセスが可能になる f.cat.name().touched(); // 有効: シグナルへのアクセス f.cat().name.touched(); // エラー: f.cat() はステートであり、子要素を持たない! ``` テンプレートでも同様です: ```html @if (bookingForm.hotelDetails.hidden()) { ... } @if (bookingForm.hotelDetails().hidden()) { ... } ``` ## Disabled / Readonly / Hidden スキーマ内のルールを使用してフィールドのステータスを制御します。 ```ts import {disabled, readonly, hidden} from '@angular/forms/signals'; userForm = form(this.userModel, (schemaPath) => { // 条件付き無効化 disabled(schemaPath.password, ({valueOf}) => !valueOf(schemaPath.createAccount)); // 条件付き非表示(モデルからは削除せず、非表示としてマークするのみ) hidden(schemaPath.shippingAddress, ({valueOf}) => valueOf(schemaPath.sameAsBilling)); // 読み取り専用 readonly(schemaPath.username); }); ``` ## バインディング `FormField` をインポートし、`[formField]` ディレクティブを使用します。 ```ts import {FormField} from '@angular/forms/signals'; ``` `disabled`、`hidden`、`readonly`、`name` などのステート上のすべてのプロパティは自動的にバインドされます。 `name` フィールドは手動でバインド_しないでください_。 **重要: 禁止属性** `[formField]` を使用する場合、テンプレートで以下の属性を設定してはなりません(静的またはバインドされた形式のいずれも): - `min`、`max`(代わりにスキーマ内のバリデーターを使用) - `value`、`[value]`、`[attr.value]`(`[formField]` によって処理済み) - `[attr.min]`、`[attr.max]` - `[disabled]`、`[readonly]`(`[formField]` によって処理済み) このようにしてはいけません: `` や ``。 ```html ``` ## リアクティブフォーム `@angular/forms` から `FormControl`、`FormGroup`、`FormArray`、`FormBuilder` を**インポートしないでください**。シグナルフォームはこれらのコンセプトを完全に置き換えます。 シグナルフォームにはビルダーがありません。 ## ステートへのアクセス フォーム内の各フィールドは、そのステートを返す関数です。 ```ts // フィールドを呼び出してアクセスする const emailState = this.userForm.email(); // 値(WritableSignal) const value = this.userForm().value(); // バリデーションステート(シグナル) const isValid = this.userForm().valid(); const isInvalid = this.userForm().invalid(); const errors = this.userForm().errors(); // エラーの配列 const isPending = this.userForm().pending(); // 非同期バリデーション待ち // インタラクションステート(シグナル) const isTouched = this.userForm().touched(); const isDirty = this.userForm().dirty(); // 可用性ステート(シグナル) const isDisabled = this.userForm().disabled(); const isHidden = this.userForm().hidden(); const isReadonly = this.userForm().readonly(); ``` 重要!: ステートを取得するには、必ずフィールドを呼び出してください。 ```ts form().invalid() form.field().dirty() form.field.subfield().touched() form.a.b.c.d().value() form.address.ssn().pending() form().reset() // 唯一の例外は length です: form.children.length form.length // 注意: 括弧なし! form.client.addresses.length // "()" なし @for (income of form.addresses; track $index) {/**/} ``` ## 送信 `submit()` 関数を使用します。アクションを実行する前に、すべてのフィールドを自動的にタッチ済みとしてマークします。 **重要**: `submit()` へのコールバックは `async` でなければならず、Promise を返す必要があります。 ```ts import { submit } from '@angular/forms/signals'; // 正しい - async コールバック onSubmit() { submit(this.userForm, async () => { // フォームが有効な場合のみ実行される await this.apiService.save(this.userModel()); console.log('Saved!'); }); } // 誤り - async キーワードが欠けている onSubmit() { submit(this.userForm, () => { // エラー: async でなければならない console.log('Saved!'); }); } ``` ## エラー処理 `field().errors()` は ValidationError の配列を返します: ```ts interface ValidationError { readonly kind: string; readonly message?: string; } ``` バリデーターから null を返さないでください。 エラーがない場合は undefined を返してください。 ### コンテキスト `validate()`、`disabled()`、`applyWhen` などのルールに渡される関数は、コンテキストオブジェクトを受け取ります。その構造を理解することが**重要**です: ```ts validate( schemaPath.username, ({ value, // Signal: フィールドの現在値(書き込み可能) fieldTree, // FieldTree: サブフィールド(グループ/配列の場合) state, // FieldState: state.valid()、state.dirty() などのフラグへのアクセス valueOf, // (path) => T: 他のフィールドの値を読む(依存関係を追跡)例: valueOf(schemaPath.password) stateOf, // (path) => FieldState: 他のフィールドのステートにアクセス 例: stateOf(schemaPath.password).valid() pathKeys, // Signal: ルートからこのフィールドへのパス }) => { // 誤り: if (touched()) ... (touched はコンテキスト内にない) // 正しい: if (state.touched()) ... if (value() === 'admin') { return {kind: 'reserved', message: 'Username admin is reserved'}; } }, ); ``` ### 重要: パスはシグナルではない `form()` コールバック内で、`schemaPath` とその子要素(例: `schemaPath.user.name`)は**シグナルではなく**、**呼び出し可能でもありません**。 ```ts // 誤り - エラーが発生します: applyWhen(p.ssn, () => p.ssn().touched(), (ssnField) => { ... }); // 正しい - stateOf() を使用してパスのステートを取得する: applyWhen(p.ssn, ({ stateOf }) => stateOf(p.ssn).touched(), (ssnField) => { ... }); // 正しい - valueOf() を使用してパスの値を取得する: applyWhen(p.ssn, ({ valueOf }) => valueOf(p.ssn) !== '', (ssnField) => { ... }); ``` ### 複数アイテム - アイテムごとにルールを適用するには `applyEach` を使用します。 - **重要**: `applyEach` のコールバックは引数を**1つだけ**取ります(アイテムパス)。2つではありません: ```ts // 正しい - 引数1つ applyEach(s.items, (item) => { required(item.name); }); // 誤り - インデックスを渡さない applyEach(s.items, (item, index) => { // エラー: コールバックは引数を1つしか取らない required(item.name); }); ``` - テンプレートではアイテムの反復に `@for` を使用します。 - 配列からアイテムを削除するには、データ内の該当アイテムをそのまま削除してください。 - **`select` バインディング**: `` (string[]) | 配列フィールドにはチェックボックスを使用 | | **readonly 属性** | `` | スキーマで `readonly()` ルールを使用 | | **min/max 属性** | `` | スキーマで `min()` と `max()` ルールを使用 | | **value バインディング** | `` | `[formField]` と共に `[value]` を使用しない | | **when オプション** | `pattern(p.x, /.../, {when: ...})` | `when` は `required()` でのみ動作する | | **Submit コールバック** | `submit(form, () => { ... })` | `submit(form, async () => { ... })` | | **Async params** | `params: s.field` | `params: ({ value }) => value()` | | **Async onError** | `onError` を省略する | `validateAsync` では `onError` は必須 | | **resource() API** | `request: signal` | `params: signal` | | **applyEach の引数** | `applyEach(s.items, (item, index) => ...)` | `applyEach(s.items, (item) => ...)` | | **ネストした @for** | `$parent.$index` | `let outerIndex = $index` を使用 | | **FormState インポート** | `import { FormState }` | `FormState` は存在しない。`FieldState` を使用 | | **モデル内の Null** | `signal({ name: null })` | `signal({ name: '' })` または `signal({ age: 0 })` | | **Validate 構文** | `validate(s.field, { value } => ...)` | `validate(s.field, ({ value }) => ...)` | | **チェックボックス配列** | `[formField]="form.tags"` (string[]) | チェックボックスは `boolean` にのみバインドする | ## 大規模フォームの例 ### `src/app/app.ts` ```ts import {Component, signal, ChangeDetectionStrategy} from '@angular/core'; import { form, FormField, submit, required, email, min, hidden, applyEach, validate, } from '@angular/forms/signals'; @Component({ selector: 'app-root', standalone: true, imports: [FormField], templateUrl: './app.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class App { model = signal({ personalInfo: { firstName: '', lastName: '', email: '', age: 0, }, tripDetails: { destination: 'Mars', launchDate: '', }, package: { tier: 'economy', extras: [] as string[], }, companions: [] as Array<{name: string; relation: string}>, }); bookingForm = form(this.model, (s) => { required(s.personalInfo.firstName, {message: 'First name is required'}); required(s.personalInfo.lastName, {message: 'Last name is required'}); required(s.personalInfo.email, {message: 'Email is required'}); email(s.personalInfo.email, {message: 'Invalid email address'}); required(s.personalInfo.age, {message: 'Age is required'}); min(s.personalInfo.age, 18, {message: 'Must be at least 18'}); required(s.tripDetails.destination); required(s.tripDetails.launchDate); validate(s.tripDetails.launchDate, ({value}) => { const date = new Date(value()); if (isNaN(date.getTime())) return undefined; const today = new Date(); if (date < today) { return {kind: 'pastData', message: 'Launch date must be in the future'}; } return undefined; }); // valueOf は、ルール内で他のフィールドの値にアクセスするために使用される hidden(s.package.extras, ({valueOf}) => valueOf(s.package.tier) === 'economy'); applyEach(s.companions, (companion) => { required(companion.name, {message: 'Companion name required'}); required(companion.relation, {message: 'Relation required'}); }); }); addCompanion() { this.model.update((m) => ({ ...m, companions: [...m.companions, {name: '', relation: ''}], })); } removeCompanion(index: number) { this.model.update((m) => ({ ...m, companions: m.companions.filter((_, i) => i !== index), })); } onSubmit() { // 重要: submit コールバックは async でなければならない submit(this.bookingForm, async () => { console.log('Booking Confirmed:', this.model()); // 非同期処理が必要な場合: // await this.apiService.save(this.model()); }); } } ``` ### `src/app/app.html` ```html

Interstellar Booking

Personal Info

Trip Details

Package

@if (!bookingForm.package.extras().hidden()) {

Extras

}

Companions

@for (companion of bookingForm.companions; track $index) {
@if (companion.name().touched() && companion.name().errors().length) { {{ companion.name().errors()[0].message }} } @if (companion.relation().touched() && companion.relation().errors().length) { {{ companion.relation().errors()[0].message }} }
}
``` ## ビルドエラーからの回復 ビルドエラーが発生した場合、最も一般的な修正方法を示します: ### `Property 'value' does not exist on type 'FieldTree'` **問題**: 最初に呼び出さずにフィールドの `.value()` に直接アクセスしています。 ```ts // 誤り const val = this.form.field.value(); // 正しい const val = this.form.field().value(); ``` ### `Property 'set' does not exist on type 'FieldTree'` **問題**: フォームツリーに対して値を設定しようとしています。シグナルフォームはモデル駆動型です。 ```ts // 誤り this.form.address.street.set('Main St'); // 正しい - 代わりにモデルシグナルを更新する this.model.update((m) => ({...m, address: {...m.address, street: 'Main St'}})); ``` ### `Type 'string[]' is not assignable to type 'string'` **問題**: 配列フィールドに単一値の ` ... ```