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