🦜

Signal Forms で動的な配列フォーム

に公開

これは Angular Advent Calendar 2025 5日目の記事です。
https://qiita.com/advent-calendar/2025/angular

はじめに

Angular v21 では、待望の Signal Forms が使えるようになりました。

この記事では、Signal Forms を使って、動的な配列フォームを実装する方法を紹介します。

Signal Forms の基本的な仕組みは説明しません。公式ドキュメントに詳しく書いてあるので、ぜひご一読ください。

動作バージョン

バージョン
% ng version

     _                      _                 ____ _     ___
    / \   _ __   __ _ _   _| | __ _ _ __     / ___| |   |_ _|
   / △ \ | '_ \ / _` | | | | |/ _` | '__|   | |   | |    | |
  / ___ \| | | | (_| | |_| | | (_| | |      | |___| |___ | |
 /_/   \_\_| |_|\__, |\__,_|_|\__,_|_|       \____|_____|___|
                |___/
    

Angular CLI       : 21.0.0
Angular           : 21.0.0
Node.js           : 22.14.0
Package Manager   : npm 10.5.0
Operating System  : darwin arm64

┌───────────────────────────┬───────────────────┬───────────────────┐
│ Package                   │ Installed Version │ Requested Version │
├───────────────────────────┼───────────────────┼───────────────────┤
│ @angular/build            │ 21.0.0            │ ^21.0.0           │
│ @angular/cli              │ 21.0.0            │ ^21.0.0           │
│ @angular/common           │ 21.0.0            │ ^21.0.0           │
│ @angular/compiler         │ 21.0.0            │ ^21.0.0           │
│ @angular/compiler-cli     │ 21.0.0            │ ^21.0.0           │
│ @angular/core             │ 21.0.0            │ ^21.0.0           │
│ @angular/forms            │ 21.0.0            │ ^21.0.0           │
│ @angular/platform-browser │ 21.0.0            │ ^21.0.0           │
│ @angular/router           │ 21.0.0            │ ^21.0.0           │
│ rxjs                      │ 7.8.2             │ ~7.8.0            │
│ typescript                │ 5.9.3             │ ~5.9.2            │
│ vitest                    │ 4.0.13            │ ^4.0.8            │
└───────────────────────────┴───────────────────┴───────────────────┘

完成したもの

コード解説

配列要素のモデル

まず、名前と年齢を持つ、シンプルなUserModelを定義します。このモデルを配列として追加・削除できるようにします。

user-model.ts
interface UserModel {
  name: string;
  age: number;
}

Userコンポーネントの実装

配列の各要素は再利用できるよう、User コンポーネントとして切り出します。

ここでポイントとなるのがFormValueControlです。

これは、コンポーネントが Signal Forms のフォームシステムとやり取りするためのインターフェースで、従来のテンプレート駆動フォーム/リアクティブフォームにおけるControl Value Accessor (CVA)に相当します。

ただし、CVA と比べると非常にシンプルで、valueという Model Input を実装するだけで、親コンポーネントのフォームと連携できます。

詳しくはカスタムコントロールをご参照ください。

Userコンポーネントでは、テンプレート駆動フォームを使って、valueを更新しています。

user.ts
@Component({selector: 'app-user'})
export class User implements FormValueControl<UserModel> {
  value = model({ name: '', age: 20 });

  updateName(name: string) {
    this.value.update((curr) => ({ ...curr, name }));
  }

  updateAge(age: number) {
    this.value.update((curr) => ({ ...curr, age }));
  }
}
user.html
<form>
  <div>
    <label>名前:
      <input
        name="name"
        [ngModel]="value().name"
        (ngModelChange)="updateName($event)"
      />
    </label>
  </div>

  <div>
    <label>年齢:
      <input
        type="number"
        name="age"
        [ngModel]="value().age"
        (ngModelChange)="updateAge($event)"
      />
    </label>
  </div>
</form>

親コンポーネントの実装

まず、ユーザー一覧をsignal<UserModel[]>として保持します。これは Signal Forms における “Source of Truth” となり、form()に渡すことでフォームインスタンスがリアクティブに生成されます。

次に、[field]ディレクティブを通して、User コンポーネントと Signal Forms を紐付けます。

parent.ts
@Component()
export class Parent {
  readonly usersModel = signal<UserModel[]>([]);
  readonly usersForm = form(this.usersModel);
}
parent.html
@for(user of usersForm; track user) {
  <app-user [field]="user" />
}

配列の追加と削除

usersModelを更新すると、フォームの状態も自動的に同期されます。

parent.ts
@Component()
export class Parent {
  addUser() {
    const userModel: UserModel = {
      name: '',
      age: 20,
    };
    this.usersModel.update((curr) => [...curr, userModel]);
  }

  deleteUser(index: number) {
    this.usersModel.update((curr) => curr.filter((_, i) => i !== index));
  }
}

バリデーションの実装

バリデーションはスキーマ関数として定義します。

例えばUserModelの場合は次のように書けます。

readonly userSchema = schema<UserModel>((schemaPath) => {
  required(schemaPath.name, { message: '名前は必須項目です' });
  required(schemaPath.age, { message: '年齢は必須項目です' });
  min(schemaPath.age, 1, {message: '年齢は0より大きい数値を入力してください'});
});

これをform()の第二引数で適用します。配列の各要素にスキーマを適用するため、applyEachを利用します。

parent.ts
@Component()
export class Parent {
  readonly usersForm = form(this.usersModel, (schemaPath) => {
    applyEach(schemaPath, userSchema);
  });
}

エラーメッセージの表示

フォームの状態を参照して、各フィールドのエラーメッセージを表示します。

parent.html
<ul>
@if(user.name().invalid()) {
  @for(error of user.name().errors(); track error) {
    <li>{{ error.message }}</li>
  }
}

@if(user.age().invalid()) {
  @for(error of user.age().errors(); track error) {
    <li>{{ error.message }}</li>
  }
}
</ul>

これで完成です!

まとめ

Signal Forms を使って、動的な配列フォームの実装方法を紹介しました。

従来のフォームシステムではどうしても複雑になりがちだった部分も、Signal ベースだとかなりスッキリまとまります。

FormValueControlと組み合わせれば、ありがちだった巨大なフォームコンポーネントともお別れできそうです。

この記事では取り上げていない「痒いところに手が届く」機能もまだ多く、従来のフォームシステムとの比較も公開されています。どのフォームシステムを採用すべきか検討する際に、参考になるはずです。

まだ実験的な位置づけではありますが、早くもプロダクトに取り入れたくなる出来です。

Signal Forms、最高。

明日は、@mashirojsさんです!

Discussion