Angular中級 #1 Reactive Forms とフォームバリデーション

読了 8分

基礎 #7HttpClient まで扱い、小さな Angular アプリのコアツールを一周しました。フォーム入力はこれまで [(ngModel)] で短く処理してきましたよね。ところが実務のフォームに入るとすぐに限界に出会います。バリデーションルールが複数重なりエラーメッセージをフィールドごとに違う形で表示しなければならずタグ入力のように項目が動的に増える フォームが出てきます。このとき、本当の武器を取り出す番です。

Angular 中級講座の最初の記事では、その武器である Reactive Forms を整理します。モデルをコードで直接定義してフォームを扱う方式なので、大きなフォーム・複雑なバリデーション・動的フォームのすべてによく合います。

2 つのフォーム方式 — 一行比較 #

Angular はフォームを扱う 2 つの公式方式を提供します。

  • Template-driven Forms — テンプレートに [(ngModel)] を付けると、Angular が裏でモデルを作ってくれます。小さなフォーム・簡単なバリデーションに軽量です。
  • Reactive Forms — TypeScript コードで FormGroupFormControl でモデルを直接作り、テンプレートはそのモデルに接続されます。モデルが明示的なので、大きなフォーム・動的フォーム・テストに強いです。

今回の記事は Reactive Forms に集中し、Template-driven は次の記事 #2 で別途扱います。どちらか一方を選ばなければならないわけではありませんが、実務プロジェクトであれば Reactive を基本に据えていくのが安全です。

ReactiveFormsModule のセットアップ #

Standalone コンポーネントでは、ReactiveFormsModule をコンポーネントの imports 配列に直接追加します。モジュールを別途作る必要はありません。

src/app/signup-form.component.ts
import { Component } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';

@Component({
  selector: 'app-signup-form',
  standalone: true,
  imports: [ReactiveFormsModule],
  templateUrl: './signup-form.component.html',
})
export class SignupFormComponent {}
注記
以前の NgModule 方式では、AppModuleimportsReactiveFormsModule を一度だけ登録しました。Standalone 時代では 使用するコンポーネントごとに 必要なモジュールを直接 import します。最初は煩わしく見えますが、コンポーネントが自分の依存関係を自分で持ち歩くという点が大きな長所です。

FormControl と FormGroup — 最も基本の単位 #

Reactive Forms の出発点は 2 つのクラスです。

  • FormControl — 入力フィールド 1 つの状態 (値・バリデーション・エラー・dirty/touched など) を持つ単位
  • FormGroup — 複数の FormControl を名前で束ねて 1 つのフォームとして扱う単位

最も単純には FormControl 1 つでも始められます。

単一入力
import { FormControl } from '@angular/forms';

name = new FormControl('');

しかし、実務のフォームはほぼ必ず複数のフィールドを束ねるので、FormGroup をよく使います。可読性のためにヘルパーである FormBuilder を使うパターンが標準です。

src/app/signup-form.component.ts
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';

@Component({
  selector: 'app-signup-form',
  standalone: true,
  imports: [ReactiveFormsModule],
  templateUrl: './signup-form.component.html',
})
export class SignupFormComponent {
  private fb = inject(FormBuilder);

  form = this.fb.group({
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(8)]],
  });
}

fb.group の各項目は [初期値, バリデータ群] 形式の配列です。この一塊を見るだけで、フォームの構造とルールがすべて現れます。モデルがコードで明示されている ことが Reactive Forms の核となる魅力です。

テンプレートと接続する #

テンプレートはそのモデルに接続するだけで済みます。[formGroup] でグループを束ね、各入力には formControlName で名前をマッチングします。

src/app/signup-form.component.html
<form [formGroup]="form" (ngSubmit)="onSubmit()">
  <label>
    メール
    <input type="email" formControlName="email" />
  </label>

  <label>
    パスワード
    <input type="password" formControlName="password" />
  </label>

  <button type="submit" [disabled]="form.invalid">登録</button>
</form>
送信ハンドラ
onSubmit() {
  if (this.form.invalid) return;
  console.log('登録情報:', this.form.value);
}

form.value で全体の値を一度に取り出し、form.invalid で送信ボタンを止めます。コンポーネントコードとテンプレートが同じモデルを共有するので、どちらの側でもフォーム状態を自由に扱えます。

バリデーション (Validators) #

@angular/formsValidators には、よく使うバリデータがあらかじめ入っています。

  • Validators.required — 空でないこと
  • Validators.minLength(n) / Validators.maxLength(n) — 長さ制限
  • Validators.email — メール形式
  • Validators.pattern(regex) — 正規表現マッチ
  • Validators.min(n) / Validators.max(n) — 数値の範囲

複数のバリデータを 1 つのフィールドに掛けるには 配列で渡せば終わり です (古い資料でよく見る Validators.compose([...]) は実は同じことをするヘルパーなので、わざわざ使わなくても構いません)。

複数バリデータの合成
form = this.fb.group({
  email: ['', [Validators.required, Validators.email]],
  password: [
    '',
    [
      Validators.required,
      Validators.minLength(8),
      Validators.pattern(/(?=.*[A-Z])(?=.*\d)/), // 大文字 1 つ以上 + 数字 1 つ以上
    ],
  ],
});

フォーム状態の読み取り — touched / dirty / valid / errors #

FormControlFormGroup は、単に値だけを持っているわけではありません。ユーザーとのインタラクション状態も同時に追跡します。

  • touched — ユーザーが一度でもフォーカスを当てて外したか (blur 発生後に true)
  • dirty — ユーザーが値を一度でも変えたか
  • valid / invalid — バリデーション通過の可否
  • errors — どのバリデータが失敗したかをオブジェクトで ({ required: true }{ minlength: { ... } } など)

これらを組み合わせて 「ユーザーが触れた後にだけエラーメッセージを表示する」 自然な UX を作れます。

エラーメッセージ表示パターン
<label>
  メール
  <input type="email" formControlName="email" />

  @if (form.controls.email.touched && form.controls.email.invalid) {
    @if (form.controls.email.errors?.['required']) {
      <small class="error">メールを入力してください。</small>
    } @else if (form.controls.email.errors?.['email']) {
      <small class="error">メール形式が正しくありません。</small>
    }
  }
</label>

毎回 form.controls.email を書き下すのが長くなれば、コンポーネントにゲッターを作るパターンがよく使われます。

getter で短く
get email() { return this.form.controls.email; }
@if (email.touched && email.errors?.['required']) {
  <small class="error">メールを入力してください。</small>
}
ヒント
エラーメッセージは ユーザーが一度でも触れた後 (touched) に表示するのが自然です。ページを開いた瞬間に赤いメッセージが並ぶと、ユーザーに小言を言っているような感じになるからです。ただし、送信時点では markAllAsTouched() ですべてのフィールドを一括で touched にし、漏れている項目を一度に伝えるパターンがよく使われます。

カスタム Validator #

組み込みのバリデータでは扱えないルールは、自分で作れば構いません。Validator は結局 1 つの関数 です — AbstractControl を受け取り、通過すれば null、失敗すれば ValidationErrors オブジェクトを返す関数です。

src/app/validators/forbidden-name.validator.ts
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

export function forbiddenName(forbidden: string): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const value = (control.value ?? '').toString().toLowerCase();
    return value.includes(forbidden.toLowerCase())
      ? { forbiddenName: { forbidden } }
      : null;
  };
}

関数がまた別の関数を返す形が不自然に見えるかもしれませんが、こうやって一段包めば 外部の引数 (forbidden) を受け取るバリデータ を作れます。使い方は他のバリデータと同じです。

nickname: ['', [Validators.required, forbiddenName('admin')]],

もう少し実戦的な例として、2 つのフィールドの値が同じか を確認するグループ単位のバリデータを見てみましょう。パスワード確認入力でよく使うパターンです。

src/app/validators/match.validator.ts
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

export function matchValidator(a: string, b: string): ValidatorFn {
  return (group: AbstractControl): ValidationErrors | null => {
    const valueA = group.get(a)?.value;
    const valueB = group.get(b)?.value;
    return valueA === valueB ? null : { mismatch: true };
  };
}
FormGroup 自体に掛けるバリデータ
form = this.fb.group(
  {
    password: ['', [Validators.required, Validators.minLength(8)]],
    passwordConfirm: ['', Validators.required],
  },
  { validators: matchValidator('password', 'passwordConfirm') },
);

このようにグループ自体に掛けたバリデータは、form.errors?.['mismatch'] として読み取り、画面に表示できます。

FormArray — 動的な項目 #

フォーム項目の数があらかじめ決まっていないとき、たとえば タグを自由に追加・削除 する入力には FormArray を使います。名前が Array であることから察せるとおり、コントロールをインデックスで扱うコレクションです。

src/app/tag-form.component.ts
import { Component, inject } from '@angular/core';
import {
  FormArray,
  FormBuilder,
  FormControl,
  ReactiveFormsModule,
  Validators,
} from '@angular/forms';

@Component({
  selector: 'app-tag-form',
  standalone: true,
  imports: [ReactiveFormsModule],
  templateUrl: './tag-form.component.html',
})
export class TagFormComponent {
  private fb = inject(FormBuilder);

  form = this.fb.group({
    title: ['', Validators.required],
    tags: this.fb.array<FormControl<string>>([]),
  });

  get tags() {
    return this.form.controls.tags;
  }

  addTag() {
    this.tags.push(this.fb.control('', { nonNullable: true }));
  }

  removeTag(i: number) {
    this.tags.removeAt(i);
  }
}

テンプレートでは formArrayName とインデックスマッチングでほどきます。

src/app/tag-form.component.html
<form [formGroup]="form">
  <input formControlName="title" placeholder="タイトル" />

  <div formArrayName="tags">
    @for (tag of tags.controls; track $index; let i = $index) {
      <div>
        <input [formControlName]="i" placeholder="タグ" />
        <button type="button" (click)="removeTag(i)">削除</button>
      </div>
    }
  </div>

  <button type="button" (click)="addTag()">+ タグ追加</button>
</form>

addTag で新しいコントロールを push し、removeTag でインデックスを取り除きます。バリデータは FormControl ごとに別々に掛けることもできますし、FormArray 自体に掛けて「最低 1 つ以上」のようなルールを作ることもできます。

非同期バリデーション (AsyncValidator) #

メールの重複チェックのように サーバーに問い合わせなければ結果がわからない バリデーションは、同期関数では解けません。このときは Observable (または Promise) を返す非同期バリデータを使います。

src/app/validators/email-taken.validator.ts
import { AsyncValidatorFn, AbstractControl } from '@angular/forms';
import { debounceTime, map, switchMap, of, take } from 'rxjs';
import { UserService } from '../user.service';

export function emailTakenValidator(users: UserService): AsyncValidatorFn {
  return (control: AbstractControl) =>
    of(control.value).pipe(
      debounceTime(300),
      switchMap(value =>
        value ? users.checkEmailTaken(value) : of(false),
      ),
      map(taken => (taken ? { emailTaken: true } : null)),
      take(1),
    );
}
コントロールへの非同期バリデータ登録
email: [
  '',
  {
    validators: [Validators.required, Validators.email],
    asyncValidators: [emailTakenValidator(inject(UserService))],
    updateOn: 'blur',
  },
],

非同期バリデーションは、ユーザーが 1 文字打つたびにサーバーを叩かないように debounceTime を置いたり、updateOn: 'blur' でフォーカスが外れた瞬間にだけバリデーションをトリガーする形で呼び出し頻度を下げるのが一般的です。バリデーション進行中は control.pendingtrue になるので、画面に「確認中…」のような表示を出せます。

まとめ #

今回の記事では Reactive Forms の核を一周しました。

  • ReactiveFormsModule を standalone コンポーネントの imports に追加
  • FormBuilder.group({...}) でモデルをコードに明示
  • テンプレートは [formGroup] + formControlName でモデルに接続
  • Validators は配列で合成し、1 つのフィールドに複数
  • touched + errors で自然なエラーメッセージ UX
  • カスタム Validator は (control) => ValidationErrors | null シグネチャの関数
  • 動的な項目は FormArray、サーバー依存のバリデーションは AsyncValidator

次の記事 #2 Template-driven Forms では、短いフォームや簡単な画面によく合う Template-driven 方式を整理します。Reactive と比較すれば、2 つの方式のうちどこに何を使えばよいか感覚がつかめるはずです。中級講座の本格的なスタートです — ゆっくりついてきてください。

X