Angularフォーム
AngularにはReactには存在しない組み込みのフォーム機能があり、リアクティブフォームとテンプレート駆動フォームの2つのアプローチを提供します。
1. テンプレート駆動フォーム
テンプレート駆動フォームは、HTMLテンプレート上のディレクティブ(ngModel など)でフォームを組み立てる方式です。フォームモデル(FormGroup / FormControl)はAngularがテンプレートから自動生成します。
FormsModule の利用
スタンドアロンコンポーネントでは、imports に FormsModule を追加します。
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-login',
standalone: true,
imports: [FormsModule],
templateUrl: './login.component.html',
})
export class LoginComponent {
model = { email: '', password: '' };
onSubmit(): void {
console.log('submit', this.model);
}
}
ngModel による双方向バインディング
[(ngModel)] は「プロパティ → 入力」と「入力 → プロパティ」を同時にバインドします。name 属性は ngModel がコントロールを登録するために必須です。
<form #f="ngForm" (ngSubmit)="onSubmit()" novalidate>
<label>
メールアドレス
<input
type="email"
name="email"
[(ngModel)]="model.email"
required
email
#email="ngModel"
/>
</label>
@if (email.invalid && (email.dirty || email.touched)) {
<p class="error">有効なメールアドレスを入力してください。</p>
}
<label>
パスワード
<input
type="password"
name="password"
[(ngModel)]="model.password"
required
minlength="8"
#password="ngModel"
/>
</label>
@if (password.errors?.['minlength']) {
<p class="error">8文字以上で入力してください。</p>
}
<button type="submit" [disabled]="f.invalid">ログイン</button>
</form>
テンプレートバリデーション
HTML標準の required、minlength、maxlength、pattern、email といった属性がそのままバリデータとして機能します。#email="ngModel" のようにテンプレート参照変数へ NgModel を取り出すと、invalid、dirty、touched、errors などの状態にアクセスできます。
| 状態 | 意味 |
|---|---|
valid / invalid | バリデーションを通過しているか |
touched / untouched | フォーカスが外れたことがあるか |
dirty / pristine | 値が変更されたか |
Reactの制御/非制御コンポーネントとの対比
| 観点 | Angular テンプレート駆動 | React |
|---|---|---|
| 双方向バインディング | [(ngModel)] が標準提供 | value + onChange を手書き(制御コンポーネント) |
| 状態の保持場所 | Angularが内部で FormControl を保持 | useState(制御)またはDOM自身(非制御 + ref) |
| バリデーション | HTML属性 + ディレクティブ | 自前実装またはライブラリ |
ngModel は「制御コンポーネントの value / onChange の定型コードを隠蔽した糖衣構文」と捉えると理解しやすくなります。
2. リアクティブフォーム
リアクティブフォームは、フォームモデルをTypeScriptのコードで明示的に定義し、テンプレートへバインドする方式です。値や状態の変化を Observable(valueChanges / statusChanges)として購読できます。
ReactiveFormsModule の利用
imports に ReactiveFormsModule を追加します。
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-profile',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './profile.component.html',
})
export class ProfileComponent {
private readonly fb = inject(FormBuilder);
form = this.fb.nonNullable.group({
name: ['', [Validators.required, Validators.maxLength(50)]],
email: ['', [Validators.required, Validators.email]],
address: this.fb.nonNullable.group({
zip: [''],
city: [''],
}),
});
onSubmit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
// 型付き: { name: string; email: string; address: { zip: string; city: string } }
console.log(this.form.getRawValue());
}
}
FormGroup, FormControl, FormArray
| クラス | 役割 |
|---|---|
FormControl | 単一の入力値とその状態(valid、dirty など) |
FormGroup | 名前付きコントロールの集合。オブジェクトに対応 |
FormArray | コントロールの可変長リスト。配列に対応 |
FormBuilder | 上記を簡潔に生成するヘルパー |
Angular 14以降はフォームが型付きで、nonNullable を使うと reset() 後も null ではなく初期値に戻る型安全な値が得られます。
formControlName ディレクティブ
[formGroup] でグループを、formControlName で各コントロールを紐付けます。ネストしたグループは formGroupName を使います。
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input type="text" formControlName="name" />
<input type="email" formControlName="email" />
<div formGroupName="address">
<input type="text" formControlName="zip" />
<input type="text" formControlName="city" />
</div>
<button type="submit" [disabled]="form.invalid">保存</button>
</form>
値の変化はストリームとして扱えます。
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { debounceTime } from 'rxjs';
constructor() {
this.form.controls.email.valueChanges
.pipe(debounceTime(300), takeUntilDestroyed())
.subscribe((v) => console.log('email changed', v));
}
React Hook Form / Formik との概念比較
| 概念 | Angular リアクティブフォーム | React Hook Form | Formik |
|---|---|---|---|
| フォーム定義 | FormGroup / FormBuilder | useForm() | useFormik() / <Formik> |
| フィールド登録 | formControlName | register('name') | getFieldProps / <Field> |
| バリデーション | Validators 関数 | rules / resolver(Zod等) | validate / Yup |
| 状態 | valid / dirty / touched | formState | errors / touched |
| 値の購読 | valueChanges(Observable) | watch() | values |
| 動的配列 | FormArray | useFieldArray | FieldArray |
React Hook Form がフォームの状態をライブラリ側に持たせる思想と近く、Angularではその仕組みがフレームワークに最初から組み込まれています。
3. バリデーション
組み込みバリデータ
Validators クラスが標準的なバリデータを提供します。
| バリデータ | 用途 |
|---|---|
Validators.required | 必須 |
Validators.email | メール形式 |
Validators.min(n) / max(n) | 数値の範囲 |
Validators.minLength(n) / maxLength(n) | 文字数 |
Validators.pattern(regex) | 正規表現 |
const password = new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(8)],
});
カスタムバリデータの作成
バリデータは AbstractControl を受け取り、エラーなら ValidationErrors オブジェクト、問題なければ null を返す関数です。
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
// 禁止ワードを含む入力を弾く(ファクトリ関数)
export function forbiddenWord(word: string): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = String(control.value ?? '');
return value.includes(word)
? { forbiddenWord: { word } }
: null;
};
}
// 複数フィールドにまたがるバリデータ(FormGroupに付与)
export const passwordsMatch: ValidatorFn = (group) => {
const pw = group.get('password')?.value;
const confirm = group.get('confirm')?.value;
return pw === confirm ? null : { passwordsMismatch: true };
};
form = this.fb.nonNullable.group(
{
username: ['', [Validators.required, forbiddenWord('admin')]],
password: ['', [Validators.required, Validators.minLength(8)]],
confirm: [''],
},
{ validators: passwordsMatch },
);
非同期バリデータ
サーバー問い合わせが必要な検証(ユーザー名の重複確認など)には非同期バリデータを使います。Observable または Promise を返します。同期バリデータが全て通過してから実行され、実行中は status が PENDING になります。
import { AsyncValidatorFn } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, map, of, switchMap, timer } from 'rxjs';
export function uniqueUsername(): AsyncValidatorFn {
const http = inject(HttpClient); // 注入コンテキスト内で呼び出すこと
return (control) =>
timer(300).pipe( // デバウンス相当
switchMap(() =>
http.get<{ exists: boolean }>('/api/users/exists', {
params: { name: control.value },
}),
),
map((res) => (res.exists ? { usernameTaken: true } : null)),
catchError(() => of(null)),
);
}
username: ['', {
validators: [Validators.required],
asyncValidators: [uniqueUsername()],
updateOn: 'blur', // 入力のたびに問い合わせない
}],
inject() を使うファクトリは、コンポーネントのフィールド初期化子など注入コンテキスト内で呼び出す必要があります。
エラーメッセージの表示パターン
touched または dirty になってから表示するのが一般的です。新しい制御フロー @if を使うと簡潔に書けます。
<input type="email" formControlName="email" />
@let email = form.controls.email;
@if (email.invalid && (email.touched || email.dirty)) {
<ul class="errors">
@if (email.hasError('required')) {
<li>メールアドレスは必須です。</li>
}
@if (email.hasError('email')) {
<li>メールアドレスの形式が正しくありません。</li>
}
</ul>
}
@if (email.pending) {
<p>確認中...</p>
}
エラーメッセージのロジックが増えてきたら、エラーキーとメッセージのマップを持つ再利用可能な FormErrorComponent として切り出すと、各フォームのテンプレートが肥大化しません。
4. フォームの動的構築
FormArray による動的フィールド
電話番号や明細行のように、ユーザー操作で増減するフィールドは FormArray で表現します。
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-order-form',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './order-form.component.html',
})
export class OrderFormComponent {
private readonly fb = inject(FormBuilder);
form = this.fb.nonNullable.group({
items: this.fb.array([this.createItem()]),
});
get items() {
return this.form.controls.items;
}
private createItem() {
return this.fb.nonNullable.group({
product: ['', Validators.required],
quantity: [1, [Validators.required, Validators.min(1)]],
});
}
addItem(): void {
this.items.push(this.createItem());
}
removeItem(index: number): void {
this.items.removeAt(index);
}
}
<form [formGroup]="form">
<div formArrayName="items">
@for (item of items.controls; track $index; let i = $index) {
<div [formGroupName]="i">
<input formControlName="product" placeholder="商品名" />
<input type="number" formControlName="quantity" />
<button type="button" (click)="removeItem(i)">削除</button>
</div>
}
</div>
<button type="button" (click)="addItem()">行を追加</button>
</form>
Reactの useFieldArray(React Hook Form)と同じ役割です。
フォームの条件付き表示
別のコントロールの値に応じて、フィールドの表示とバリデーションを切り替えます。表示は @if、バリデーションの有効化は setValidators で行います。
constructor() {
this.form.controls.contactType.valueChanges
.pipe(takeUntilDestroyed())
.subscribe((type) => {
const phone = this.form.controls.phone;
if (type === 'phone') {
phone.setValidators([Validators.required]);
} else {
phone.clearValidators();
phone.reset();
}
phone.updateValueAndValidity();
});
}
<select formControlName="contactType">
<option value="email">メール</option>
<option value="phone">電話</option>
</select>
@if (form.controls.contactType.value === 'phone') {
<input type="tel" formControlName="phone" />
}
setValidators の後は必ず updateValueAndValidity() を呼び、再評価を促します。
5. どちらのフォームを選ぶべきか
| 観点 | テンプレート駆動 | リアクティブ |
|---|---|---|
| 定義場所 | テンプレート | コンポーネントクラス |
| データフロー | 非同期(ngModel 経由) | 同期・明示的 |
| 型安全性 | 弱い | 型付きフォームで強い |
| 動的フィールド | 難しい | FormArray で容易 |
| 複雑なバリデーション | 不得意 | カスタム/非同期/クロスフィールドに強い |
| ユニットテスト | DOMとテンプレートが必要 | クラス単体で検証可能 |
| コード量(小規模) | 少ない | やや多い |
- テンプレート駆動: ログインや検索ボックスなど、項目が少なく検証もシンプルなフォームに向きます。
- リアクティブ: 項目が多い、動的に増減する、フィールド間の依存がある、といった複雑なフォームや、バリデーションロジックを単体テストしたい場合に適しています。
実務での推奨はリアクティブフォームです。型安全性、テスト容易性、動的構築の柔軟さに優れ、規模が大きくなってもコードの見通しを保てます。1つのプロジェクト内で方式が混在すると学習コストが増えるため、基本はリアクティブに統一し、ごく単純なフォームだけ例外とするのが無難です。