🐭

Signal Forms(プロトタイプ)のチュートリアル

に公開

Signal Forms

現在開発中のAngular次世代フォームAPI。ここ数年、AngularのAPIは既存の仕組みから signalベースのリアクティブAPIへと移行しつつある。

従来のフォームAPIには次の2種類が存在する。

  • テンプレート駆動フォーム
  • リアクティブフォーム

そこに 第三の選択肢として加わるのがSignal Forms。AngularはsignalベースのAPIを主軸に据える方向へ進んでおり、フォーム実装のファーストチョイスになる可能性が高い。

Signal Formsは現在 PoC(Proof of Concept)段階にあり、以下のブランチでプロトタイプが開発されている。

https://github.com/angular/angular/tree/prototype/signal-forms/packages/forms/signals

概要や設計思想については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に実装し、状態を保持する。

app.ts
@Component()
export class AppComponent {
  readonly data = signal<Feedback>({
    name: '',
    email: '',
    password: '',
    confirmationPassword: '',
    recommendToFriends: false,
    friends: [],
  });
}

フォームインスタンスの作成

Signal Formsはフォーム内部に状態を保持しない。

代わりに、signalで定義したデータモデルを唯一の情報源(Source of Truth) として扱い、そこからform()によってフォームインスタンスをリアクティブに生成する。

app.ts
@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と同じイメージ。

app.html
<mat-form-field>
  <mat-label>Name</mat-label>
  <input [control]="form.name" matInput>
</mat-form-field>

バリデーション

組み込みバリデーション

nameに組み込みの必須バリデーションrequiredを追加する。

app.ts
@Component()
export class AppComponent {
  readonly form = form(this.data, (path) => {
    required(path.name);
  });
}

form.name().valid()form.name().errors()nameフォームの状態が取得できる。例えば、テンプレートでエラーを表示したい場合はこうする。

app.html
<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に必須バリデーションと、カスタムバリデーションを追加する。

app.ts
@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' };
};

クロスバリデーション

passwordconfirmationPasswordに必須バリデーションを追加。
さらにconfirmationPasswordpasswordとの一致をチェックするクロスバリデーションを追加する。

valueOfvalidate関数内で呼び出すことで、特定のフォームフィールドの値を取得できる。

app.ts
@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にする。

app.ts
@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用の再利用可能なスキーマを定義する。

friend.ts
export const friendSchema = schema<Friend>((friend) => {
  required(friend.name);
  required(friend.email);
  validate(friend.email, emailValidator);
});

このように、バリデーションや無効化などのロジックを、TypeScriptで宣言的に定義できることが、Signal Formsの大きな特徴のひとつ。
フォームの構造を定義する段階で、状態を決定するルールや条件(スキーマ)をあらかじめ指定できる。

従来のリアクティブフォームでは、フォームインスタンスを作成するときにロジックを組み込む必要があり、動的にフォームを追加するケースではコードが煩雑になりやすかった。
Signal Formsはこの問題を根本から解消し、スキーマによって 再利用性が高く、明快なフォーム定義を可能にしている。

FriendComponentの実装

friend.ts
@Component()
export class FriendComponent {
  friend = input.required<Field<Friend>>();
}
friend.html
@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を適用し、配列に追加する処理を実装する。

app.ts
@Component()
export class AppComponent {
  readonly form = form(this.data, (path) => {
    applyEach(path.friends, friendSchema);
  });
}

FriendComponentの利用

app.html
@for (friend of form.friends; track friend) {
  <app-friend [friend]="friend" />
}

配列の追加

app.ts
@Component()
export class AppComponent {
  addFriend() {
    this.form.friends().value.update((f) => [...f, { name: '', email: '' }]);
    // 以下でも可
    // this.data.update((d) => ({ ...d, friends: [...d.friends, { name: '', email: '' }] }));
  }
}
app.html
<button type="button" matButton="outlined" (click)="addFriend()">Add Friend</button>

hidden

friendsrecommendToFriendsのチェックボックスがtrueの場合のみ表示する。

app.html
<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なままになってしまう。

これについては、以下のように、recommendToFriendsfalseの場合に、friendsをhiddenにすることで、解決できる。

app.ts
@Component()
export class AppComponent {
  readonly form = form(this.data, (path) => {
    hidden(path.friends, ({ valueOf }) => {
      return valueOf(path.recommendToFriends) === false;
    });
  });
}

あるいは、applyWhenを使って、特定の条件の場合にのみ、バリデーションを有効化することもできる。

app.ts
@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