メインコンテンツまでスキップ

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 FormFormik
フォーム定義FormGroup / FormBuilderuseForm()useFormik() / <Formik>
フィールド登録formControlNameregister('name')getFieldProps / <Field>
バリデーションValidators 関数rules / resolver(Zod等)validate / Yup
状態valid / dirty / touchedformStateerrors / touched
値の購読valueChanges(Observable)watch()values
動的配列FormArrayuseFieldArrayFieldArray

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つのプロジェクト内で方式が混在すると学習コストが増えるため、基本はリアクティブに統一し、ごく単純なフォームだけ例外とするのが無難です。