対象読者
- Angular 21 で導入された Signal Forms が気になっている方
- 既存の Reactive Forms(Typed Forms)との違いを把握したい方
- 新規プロジェクトで Signal Forms を採用すべきかを判断したい方
- Signals ベースのフォーム設計に興味がある方
説明すること・しないこと
説明すること
- Signal Forms と Reactive Forms(Typed Forms)の設計思想の違い
- 同じログインフォームを両方で書いた場合の比較
- Signal Forms の主要 API(form 関数、FormField、FormRoot、schema 関数、バリデータ、submission.action)
- Signal Forms の良いと感じる点
- Signal Forms を使う上での注意点
説明しないこと
- テンプレート駆動型フォームの詳細
- Reactive Forms の細かい挙動
この記事のゴール
Angular 21 の Signal Forms と Reactive Forms(Typed Forms)の違いを整理し、Signal Forms の良さを理解すること。
経緯
Angular で Signal Forms が experimental とは言え、リリースされて既に半年となりました!!!
そして、Signal Forms と httpResource を含む resource API は、再来週(2026 年 6 月第 1 週)にリリースされる Angular v22 から GA(stable)になる予定 です。
experimental 期間がもうすぐ終わるタイミングなので、今のうちに触っておくと v22 リリース後すぐに本番投入できる状態を作れます!!!
参照:Angular 22 Release Guide - CMARIX / angular.love on X
Signal Forms の便利さは使ってみればわかるかと思いますが、イメージが沸かない人のために Google Developer Expert for Angular の方がサンプルを作成していたのでシェアします。
参照:lacolaco/angular-signal-forms-examples
肝心の Reactive Forms との違いを、公式ドキュメントを読みつつ実際に書いて確かめてみます。
参照:Announcing Angular v21 - Angular Blog
Signal Forms とは
Signal Forms は、Angular 21 で導入された Signals ベースの新しいフォーム API です。@angular/forms/signals から提供されます。
参照:Forms Overview - Angular
参照:Comparison with other form approaches - Angular
ドキュメントから引用すると、3 つのフォームアプローチは以下のように整理されています。
| 観点 | Signal Forms | Reactive Forms | Template-driven Forms |
|---|---|---|---|
| 信頼できる情報源 | ユーザー定義の writable signal モデル | FormControl / FormGroup | コンポーネント内のモデル |
| 型安全性 | モデルから自動推論 | Typed Forms で明示 | 最小限 |
| バリデーション | パスベースの schema | コントロールに渡すリスト | ディレクティブベース |
| 状態管理 | Signal ベース | Observable ベース | Angular 管理 |
| ライフサイクル管理 | submission.action で宣言的 | (ngSubmit) + 自前メソッドで手続き的 | ディレクティブ依存 |
| 学習コスト | 中 | 中〜高 | 低 |
| ステータス | Experimental(v21〜)/ v22 で Stable 予定 | Stable | Stable |
この表の中で、もっとも本質的な違いは 「信頼できる情報源(source of truth)」がどこにあるか という点になります。
設計思想の違い:データはどこに置かれるのか
Reactive Forms の場合
フォームの値は FormControl / FormGroup インスタンスの中に格納されます。コンポーネントのデータモデルとは別にフォーム状態を管理する仕組みです。
const credentials = this.loginForm.getRawValue();
// { email: '...', password: '...' }
Signal Forms の場合
フォームの値は ユーザーが定義した writable signal の中 に格納されます。フォーム構造はデータモデルをそのまま反映する形になります。
const credentials = this.loginModel();
// { email: '...', password: '...' }
つまり、Reactive Forms が「データモデルとフォーム状態を分離」する設計だったのに対し、Signal Forms は「データモデル=フォーム状態」という設計に変わっています。これが Signal Forms の最大のポイントです。
同じログインフォームを両方で書いてみる
公式ドキュメント(参照:Comparison with other form approaches - Angular)の比較例をベースに、同じログインフォームを両方の方式で書いてみます。
Reactive Forms(Typed Forms)版
参照:Strictly typed reactive forms - Angular
import { Component } from '@angular/core';
import {
FormGroup,
FormControl,
Validators,
ReactiveFormsModule,
} from '@angular/forms';
interface LoginFormModel {
email: FormControl<string>;
password: FormControl<string>;
}
@Component({
selector: 'app-login-reactive',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="loginForm" (submit)="onSubmit()">
<label>
Email
<input type="email" formControlName="email" />
</label>
@if (loginForm.controls.email.touched && loginForm.controls.email.invalid) {
<span class="error">
@if (loginForm.controls.email.errors?.['required']) {
Email is required
}
@if (loginForm.controls.email.errors?.['email']) {
Enter a valid email address
}
</span>
}
<label>
Password
<input type="password" formControlName="password" />
</label>
@if (loginForm.controls.password.touched && loginForm.controls.password.invalid) {
<span class="error">
@if (loginForm.controls.password.errors?.['required']) {
Password is required
}
@if (loginForm.controls.password.errors?.['minlength']) {
Password must be at least 6 characters
}
</span>
}
<button type="submit">Sign In</button>
</form>
`,
})
export class LoginReactive {
loginForm = new FormGroup<LoginFormModel>({
email: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.email],
}),
password: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(6)],
}),
});
onSubmit() {
this.loginForm.markAllAsTouched();
if (this.loginForm.valid) {
const credentials = this.loginForm.getRawValue();
console.log('Submitting:', credentials);
}
}
}
Reactive Forms(Typed Forms)のコードは、型のために FormGroup<LoginFormModel> や nonNullable: true を毎フィールドに書く必要があり、未タッチ送信時のエラー表示のために markAllAsTouched() を手動で呼ぶ必要があるなど、「既存 API に型付けを後付けした」ことに起因する記述の多さ が見えます。
【補足】Reactive Forms は Angular v2(2016 年)から存在していた API で、Angular v14(2022 年 6 月)で Strictly Typed Reactive Forms として型付け機能が後付けで追加されました。既存コードベースとの後方互換性のため、UntypedFormControl / UntypedFormGroup という型なし版が今でも併存しています。
参照:Strictly typed reactive forms - Angular / Angular v14 is now available - Angular Blog
Signal Forms 版
同じものを Signal Forms で書くと、以下のようになります。
Angular 21.2 で導入された FormRoot ディレクティブ を使うパターンです。公式ドキュメントでは「FormRoot がフォーム送信を簡潔にする(simplifies form submission)」と紹介されています。
参照:Field state management - Angular
import { Component, signal } from '@angular/core';
import {
email,
form,
FormField,
FormRoot,
minLength,
required,
} from '@angular/forms/signals';
@Component({
selector: 'app-login',
imports: [
FormField,
FormRoot
],
template: `
<form [formRoot]="loginForm">
<label>
Email
<input type="email" [formField]="loginForm.email" />
</label>
@if (loginForm.email().touched() && loginForm.email().invalid()) {
<span class="error">
{{ loginForm.email().errors()[0].message }}
</span>
}
<label>
Password
<input type="password" [formField]="loginForm.password" />
</label>
@if (loginForm.password().touched() && loginForm.password().invalid()) {
<span class="error">
{{ loginForm.password().errors()[0].message }}
</span>
}
<button type="submit" [disabled]="loginForm().submitting()">
{{ loginForm().submitting() ? 'Sending...' : 'Sign In' }}
</button>
</form>
`,
})
export class Login {
loginModel = signal({
email: '',
password: '',
});
loginForm = form(
this.loginModel,
(f) => {
required(f.email, { message: 'Email is required' });
email(f.email, { message: 'Enter a valid email address' });
required(f.password, { message: 'Password is required' });
minLength(f.password, 6, {
message: 'Password must be at least 6 characters',
});
},
{
submission: {
action: async () => {
const credentials = this.loginModel();
console.log('Logging in with:', credentials);
},
},
},
);
}
参照:Forms with signals - Angular
注目すべき変化は以下になります。
-
new FormGroup({...})が消えて、signal({...})+form()の組み合わせ になった - バリデーションが schema 関数 1 箇所に集約 された
- エラーメッセージがバリデータ定義時に紐づく ようになり、テンプレートが簡潔になった
-
formControlName="email"のような文字列指定がなくなり、[formField]="loginForm.email"のような型付き参照 になった -
(ngSubmit)+ onSubmit メソッドが消えて、submission.action に集約 された - markAllAsTouched() の手動呼び出しが不要 になった(FormRoot が自動)
- 型が signal モデルから自動推論 され、interface 定義や nonNullable: true といった明示が消えた
参考までに、loginForm.email のように書けるのは、TypeScript が signal のモデル構造から自動的に型を推論しているからです。
[formRoot] ディレクティブの役割
Signal Forms の Reactive Forms との違いを理解する上で、[formRoot] の存在は重要です。
| Reactive Forms | Signal Forms | 役割 |
|---|---|---|
[formGroup] |
[formRoot] |
<form> 要素に付けるフォーム全体管理ディレクティブ |
formControlName |
[formField] |
個別の <input> に付けるフィールドバインドディレクティブ |
[formRoot] をフォームに付けると、以下が すべて自動 で行われます。
- novalidate 属性の自動付与(ブラウザネイティブバリデーション抑止)
- submit 時の event.preventDefault() 自動呼び出し
- submit 時の submit() 関数の自動呼び出し
- 全フィールドの自動 touched 化
- submission.action の自動実行(valid な時のみ)
参照:Field state management - Angular
Signal Forms が良いと感じる点
Signal Forms を実際に書いてみた上で、特に良いな。
と感じた点を整理します。
型安全性が「明確」になった
Reactive Forms の Typed Forms は、既存 API に型を後付けした性質上、interface 定義、FormGroup<T> ジェネリクス、nonNullable: true の付与、.getRawValue() の使い分けなど、型のための記述が多く必要 でしたが、、、
Signal Forms は TypeScript を最優先にゼロから設計 されているため、モデルの型がそのままフォームの型になります。
Typed Reactive Forms were a compromise – typing was added to existing API. Signal forms were designed from scratch with TypeScript as a priority. The difference is noticeable from the first line of code.
参照:Angular Signal Forms — The Complete Guide - Angular.love
// Signal Forms の型推論
loginModel = signal({ email: '', password: '' });
loginForm = form(this.loginModel);
// 型注釈なしで完全な型推論
this.loginModel()
// 型: { email: string; password: string }(Partial も null もなし)
loginForm.email()
// 型: FieldState<string>(TypeScript が自動推論)
バリデーションが 1 箇所に集約される
Reactive Forms では、バリデータの登録はコントロール定義時、エラーメッセージはテンプレート、という具合に分散していましたが、Signal Forms では schema 関数の中に 「どのフィールドに、どんなバリデーションを、どんなメッセージで」 を全部書けます!
loginForm = form(this.loginModel, (fieldPath) => {
required(fieldPath.email, { message: 'Email is required' });
email(fieldPath.email, { message: 'Enter a valid email address' });
});
フォームのライフサイクルが宣言的に管理できる
Reactive Forms では、フォーム定義と送信処理が 別々に書かれる手続き的なスタイル でした。
Signal Forms は form() の中にライフサイクル管理が組み込まれた宣言的スタイル に変わっています。
loginForm = form(model, schema, {
submission: {
action: async () => {
// 送信処理(valid な時のみ自動実行)
},
},
});
submission は フォーム送信のライフサイクル全体を自動でハンドリング してくれる設定です。公式ドキュメントによると、submit イベントが発火すると以下の 4 つが自動で順番に実行されます。
- 全フィールドの touched 化(hidden / disabled / readonly はスキップ)
- バリデーションチェック(失敗時は action を実行しない)
-
action の実行(valid な時のみ、実行中は
submitting()が true) - 結果のハンドリング(action が返したエラーを対象フィールドに自動で振り分け)
開発者は「valid な時に何をするか」だけを action に書けば、それ以外の手続きを書く必要がありません!
submission オプションには action 以外にも、以下のような設定が用意されています。
-
onInvalid: バリデーション失敗時のコールバック(例:最初のエラーフィールドにフォーカスする、トーストを表示する、エラー位置までスクロールする) -
ignoreValidators: バリデーション実行の制御。'pending'(デフォルト、pending な validator は無視)/'none'(pending も含めて全て pass しないと送信不可)/'all'(バリデーション状態に関わらず常に送信)の 3 つから選択。
テンプレートのディレクティブが整理された
Reactive Forms では formGroup、formControlName、formArrayName、(ngSubmit) など、複数のディレクティブを使い分けていました。Signal Forms では [formRoot] と [formField] の 2 つに整理されています。
【補足】Angular 21.1 で [field] ディレクティブから [formField] ディレクティブへの名前変更が推奨されました。field は他のライブラリやカスタムディレクティブと衝突する可能性があるため、より明示的な命名に変更されています。field も引き続き動作しますが、将来的に非推奨になる予定です。
参照:Angular 21.1 Release - angular.courses
未タッチ送信時のバリデーション表示が自動化された
Reactive Forms では、未タッチで送信されたフィールドのエラーを表示するために、毎回 markAllAsTouched() を呼ぶ必要がありました。Signal Forms では FormRoot ディレクティブが自動で全フィールドを touched にマーク するため、開発者の手間が削減されています。
Signal Forms を使う上での注意点
実際に Signal Forms を書いて動かしてみると、いくつかの 「知らないとハマる」ポイント が見えてきました。
注意点 1: FormRoot を使わない場合は novalidate が必須
FormRoot ディレクティブを使わず、手動で submit() 関数を呼ぶパターンの場合、<form> に novalidate 属性を明示的に付ける必要 があります。
<!-- NG: ブラウザのネイティブバリデーションが先に発火する -->
<form (submit)="onSubmit($event)">
<input id="email" type="email" [formField]="loginForm.email" />
</form>
<!-- OK: novalidate でネイティブバリデーションを抑止 -->
<form (submit)="onSubmit($event)" novalidate>
<input id="email" type="email" [formField]="loginForm.email" />
</form>
これを付けないと、ブラウザの「このフィールドを入力してください」というポップアップが先に発火し、(submit) イベントが発火しません。Reactive Forms の [formGroup] はこれを自動でハンドリングしていましたが、Signal Forms では FormRoot を使うか、自分で novalidate を書く必要 があります。
参照:Issue #65429 - Mark as touched should not work on disabled field
注意点 2: ボタンの [disabled] は invalid() で制御しない
未タッチ送信時のエラー表示を実現するには、ボタンの disabled 制御に注意が必要です。
<!-- NG: 未入力時にボタンが押せず、エラーメッセージが出ない -->
<button [disabled]="loginForm().invalid()">Sign In</button>
<!-- OK: submitting() のみで制御 -->
<button [disabled]="loginForm().submitting()">Sign In</button>
invalid() で disable してしまうと、未入力時にそもそも押せないため submission.action も呼ばれず、エラーメッセージが出ない、という現象が起きます。FormRoot の効果を活かすには、submitting() のみで制御するのが正しいパターンです。
注意点 3: markAllAsTouched() のような直接 API は存在しない
Signal Forms には Reactive Forms の markAllAsTouched() のような フォーム全体を一括 touched にする API は提供されていません。
loginForm().markAsTouched() を呼んでも ルートフィールドのみが touched となり、子フィールドには伝播しません。
// NG: ルートだけ touched になり、email / password は false のまま
this.loginForm().markAsTouched();
// 結果: root=true, email=false, password=false
【補足】Signal Forms は内部的に 「ルート(RootFieldContext)」と「子フィールド(ChildFieldContext)」の階層構造 を持っています。
-
ルートフィールド(
RootFieldContext): フォーム全体を表す。loginForm()で取得できる(= ルートのFieldState) -
子フィールド(
ChildFieldContext): 各入力項目を表す。loginForm.email()、loginForm.password()で取得できる(= 子のFieldState)。ChildFieldContextはRootFieldContextを継承し、key(親内での自分のプロパティ名)を追加で持つ -
配列要素(
ItemFieldContext):applyEach等で扱う配列要素。ChildFieldContextを継承し、index(配列内インデックス)を追加で持つ
つまり、loginForm(関数呼び出しなし)は フィールドツリー(FieldTree) で、そこから () で呼び出すと その階層の FieldState が返ってきます。markAsTouched() は 呼び出した階層の FieldState にしか作用しない ため、ルートで呼んでも子フィールドには伝播しません。
FormRoot ディレクティブは、この階層を 再帰的に走査して全フィールドを touched にする 処理を内部で行っているため、手動で書く必要がなくなる、というのが便利さの正体です。
参照:Issue #65031
まとめ
Angular 21 の Signal Forms は、Reactive Forms の進化版というよりも、Signals に合わせてフォーム API をゼロから再設計したもの という位置付けになります。
「データモデル=フォーム状態」というシンプルな構造、schema 関数による一元的なバリデーション、モデルから自動推論される型安全性、submission.action による宣言的なライフサイクル管理、そして FormRoot ディレクティブによる submit 処理の自動化が、Signal Forms の最大の魅力です!!!
そして再来週リリースされる Angular v22 で stable(GA)になる ことが決まっています。
新規プロジェクトでは積極的に試す価値があります。
既存の大規模な Reactive Forms 資産がある場合でも、Angular は公式ドキュメントで 「Reactive Forms との相互運用性(interoperability)」 を重視した移行ガイドを提供しており、フォーム単位・フィールド単位でゆっくり移行できます。
-
compatForm(v21.0〜、@angular/forms/signals/compatから提供): 既存のFormControl/FormGroupを Signal Form の中にそのまま組み込める API。 -
SignalFormControl(v21.2〜): 逆方向のアプローチ。既存の Reactive Form の中に 新規フィールドだけ Signal ベースで追加 できます。
これらを組み合わせれば、複雑な既存バリデータやサードパーティライブラリも捨てずに、徐々に Signal Forms に寄せていく ことが可能です!!
参照:Migrating from Reactive Forms - Angular
最後まで読んでいただきありがとうございました。