1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Angularバリデーション入門:Reactive Formsで「ルール定義→表示→送信制御」まで最短で押さえる

Posted at

はじめに

結論:Angularのバリデーションは、Reactive Formsで「ルールをTSに集約して、状態を見て表示し、送信で確定させる」順に押さえるのが近道です。

この記事は、Angularでフォームを作り始めたばかりで「required はできたけど、エラー表示やパスワード一致みたいな要件で手が止まる」人に向けています。初心者でも迷いにくいように、最小構成のコードから積み上げます。

先に成果物(コピペ用テンプレ)を置きます。エラーメッセージを1か所に集約するための関数です。

// validation-message.ts
import { ValidationErrors } from '@angular/forms';

export function firstErrorMessage(errors: ValidationErrors | null | undefined): string | null {
  if (!errors) return null;

  if (errors['required']) return '必須です';
  if (errors['email']) return 'メール形式が正しくありません';

  if (errors['minlength']) {
    const { requiredLength } = errors['minlength'] as { requiredLength: number };
    return `最低${requiredLength}文字で入力してください`;
  }

  if (errors['fieldsMismatch']) return '入力が一致しません';

  return '入力内容を確認してください';
}

この記事は「入門/チュートリアル」型で、次のチェックリストを満たす形をゴールにします。

  • バリデーションルールをTS側(Validatorsやカスタム関数)で定義できる
  • エラー表示は dirty / touched を条件にして「早すぎる表示」を防げる
  • 送信時に markAllAsTouched() で未操作項目もまとめてチェックできる
  • 「パスワード一致」など複数項目ルールを FormGroup のバリデーターで書ける

この記事は「入門/チュートリアル」型です。まずは動く最小構成を作り、そこから実務で増えがちな要素(カスタム・非同期・UX)を足していきます。

Angularのバリデーション全体像

Angularのフォームは大きく Reactive FormsTemplate-driven Forms の2つのアプローチがあります。1
どちらでも「入力値」と「バリデーション状態」を扱えますが、実務ではルールが増えやすいので、ルールをTSに集約しやすいReactive Formsが選ばれることが多いです(ここは経験則です)。

また、Angular Formsを使う場合、ブラウザ標準のバリデーションUIが干渉しないように、Angularが <form>novalidate を自動付与します。標準UIを明示的に使いたいときは ngNativeValidate を付けます。2

「ブラウザの赤い吹き出し(標準UI)を出したいのに出ない」場合、Angular側が novalidate を付けている可能性があります。ngNativeValidate を検討してください。2

Reactive Formsで最小実装(TS/HTML/送信)

ここからはReactive Formsで、必須・文字数・メール形式の基本を一気に揃えます。Reactive Formsは「モデル駆動」でフォーム状態を扱うアプローチとして案内されています。3

TypeScript:FormGroupとValidatorsを定義する

standalone: true のコンポーネントに、フォーム定義と送信処理を置きます。

// signup.component.ts
import { Component } from '@angular/core';
import {
  AbstractControl,
  FormControl,
  FormGroup,
  ReactiveFormsModule,
  ValidationErrors,
  Validators,
} from '@angular/forms';
import { firstErrorMessage } from './validation-message';

type SignupForm = FormGroup<{
  name: FormControl<string>;
  email: FormControl<string>;
  password: FormControl<string>;
  confirmPassword: FormControl<string>;
}>;

@Component({
  selector: 'app-signup',
  standalone: true,
  imports: [ReactiveFormsModule],
  templateUrl: './signup.component.html',
})
export class SignupComponent {
  readonly form: SignupForm = new FormGroup(
    {
      name: new FormControl('', {
        nonNullable: true,
        validators: [Validators.required, Validators.minLength(2)],
      }),
      email: new FormControl('', {
        nonNullable: true,
        validators: [Validators.required, Validators.email],
      }),
      password: new FormControl('', {
        nonNullable: true,
        validators: [Validators.required, Validators.minLength(8)],
      }),
      confirmPassword: new FormControl('', {
        nonNullable: true,
        validators: [Validators.required],
      }),
    },
    { validators: [matchFieldsValidator('password', 'confirmPassword')] }
  );

  // テンプレで使いやすいように getter を用意します
  get name() {
    return this.form.controls.name;
  }
  get email() {
    return this.form.controls.email;
  }
  get password() {
    return this.form.controls.password;
  }
  get confirmPassword() {
    return this.form.controls.confirmPassword;
  }

  // 成果物テンプレ(エラーメッセージ生成)をコンポーネントから使うために公開します
  firstErrorMessage = firstErrorMessage;

  submit(): void {
    if (this.form.invalid) {
      // 送信時は「未操作の項目」も含めてエラー表示できるようにします
      this.form.markAllAsTouched();
      return;
    }

    // getRawValue() は typed forms と相性が良いです(nullを含みにくい)
    const value = this.form.getRawValue();
    console.log('submit', value);
  }
}

// 複数項目(クロスフィールド)の例:password と confirmPassword が一致するか
function matchFieldsValidator(aKey: string, bKey: string) {
  return (control: AbstractControl): ValidationErrors | null => {
    // FormGroup想定ですが、安全のため get で取得します
    const a = control.get(aKey);
    const b = control.get(bKey);
    if (!a || !b) return null;

    // 片方が未入力の段階では、過剰にエラーを出さない方がUXが良いことが多いです(経験則)
    if (!a.value || !b.value) return null;

    return a.value === b.value ? null : { fieldsMismatch: true };
  };
}

Reactive Formsは「型付き」を前提に扱えるようになっており、Angular 14以降は厳密な型付けがデフォルトです。4

HTML:dirty/touchedを見てエラーを出す

Angular公式ガイドでは、ユーザーが編集する前にエラーを出しすぎないために、dirty または touched を条件にするのが推奨されています。5
その形に合わせて書きます。

<!-- signup.component.html -->
<form [formGroup]="form" (ngSubmit)="submit()">
  <h2>サインアップ</h2>

  <label>
    名前
    <input type="text" formControlName="name" />
  </label>

  @if (name.invalid && (name.dirty || name.touched)) {
    <p class="error">{{ firstErrorMessage(name.errors) }}</p>
  }

  <label>
    メール
    <input type="email" formControlName="email" />
  </label>

  @if (email.invalid && (email.dirty || email.touched)) {
    <p class="error">{{ firstErrorMessage(email.errors) }}</p>
  }

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

  @if (password.invalid && (password.dirty || password.touched)) {
    <p class="error">{{ firstErrorMessage(password.errors) }}</p>
  }

  <label>
    パスワード(確認)
    <input type="password" formControlName="confirmPassword" />
  </label>

  @if (confirmPassword.invalid && (confirmPassword.dirty || confirmPassword.touched)) {
    <p class="error">{{ firstErrorMessage(confirmPassword.errors) }}</p>
  }

  @if (form.errors?.['fieldsMismatch'] && (confirmPassword.dirty || confirmPassword.touched)) {
    <p class="error">パスワードが一致しません</p>
  }

  <button type="submit" [disabled]="form.invalid">登録</button>
</form>

<style>
  form {
    display: grid;
    gap: 12px;
    max-width: 420px;
  }
  label {
    display: grid;
    gap: 6px;
  }
  .error {
    margin: 0;
    font-size: 0.9rem;
  }
</style>

テンプレ内の @if はAngularの制御フロー構文です。プロジェクト事情で *ngIf を使っている場合は、同じ条件式に置き換えればOKです(本記事の主題はバリデーションなので、ここは読み替えポイントです)。

ここまでで何ができているか

この時点で、次が揃います。

  • 入力ルール:Validators.required / Validators.minLength / Validators.email などの組み込みバリデーターを使える6
  • 表示UX:dirty || touched を条件にして「触ってからエラー」を出せる5
  • 送信制御:form.invalid でボタン非活性、送信時に markAllAsTouched() で一括チェック

Template-driven Formsで最小実装(まずは雰囲気を掴む)

Template-driven Formsは、テンプレ側のディレクティブ(属性)中心に組み立てるスタイルです。公式チュートリアルでも「テンプレ駆動フォームの作成」として、入力とバリデーションを扱う流れが説明されています。7

ここでは最小例だけ置きます。

// profile.component.ts
import { Component } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';

@Component({
  selector: 'app-profile',
  standalone: true,
  imports: [FormsModule],
  templateUrl: './profile.component.html',
})
export class ProfileComponent {
  model = { name: '', email: '' };

  submit(form: NgForm): void {
    if (form.invalid) {
      // Template-drivenでも、送信でまとめて触るのが分かりやすいことが多いです(経験則)
      form.control.markAllAsTouched();
      return;
    }
    console.log('submit', this.model);
  }
}
<!-- profile.component.html -->
<form #f="ngForm" (ngSubmit)="submit(f)">
  <h2>プロフィール</h2>

  <label>
    名前
    <input
      type="text"
      name="name"
      [(ngModel)]="model.name"
      required
      minlength="2"
      #name="ngModel"
    />
  </label>

  @if (name.invalid && (name.dirty || name.touched)) {
    <p class="error">名前は2文字以上で入力してください</p>
  }

  <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>
  }

  <button type="submit" [disabled]="f.invalid">保存</button>
</form>

Template-drivenは「見た目に近い場所でルールが読める」良さがあります。一方で、ルールが増えたり再利用したくなると、Reactive Formsの方が整理しやすい場面が増えがちです(ここは状況依存です)。

カスタムバリデーション(単項目・複数項目・非同期の考え方)

バリデーションが「required」だけで終わることは少なく、実務では次の3つが増えやすいです。

  1. 単項目の独自ルール(例:禁止文字)
  2. 複数項目の整合性(例:確認入力一致)
  3. サーバー確認が必要(例:ユーザー名の重複チェック)

単項目:禁止文字を弾く(ValidatorFn)

// forbidden-words.validator.ts
import { AbstractControl, ValidationErrors } from '@angular/forms';

export function forbiddenWordsValidator(forbidden: RegExp) {
  return (control: AbstractControl): ValidationErrors | null => {
    const value = String(control.value ?? '');
    return forbidden.test(value) ? { forbiddenWords: true } : null;
  };
}

使う側(FormControlのvalidatorsに追加):

import { forbiddenWordsValidator } from './forbidden-words.validator';

name: new FormControl('', {
  nonNullable: true,
  validators: [Validators.required, Validators.minLength(2), forbiddenWordsValidator(/admin|root/i)],
}),

表示側(メッセージ辞書に足す):

if (errors['forbiddenWords']) return 'その文字列は使用できません';

複数項目:一致チェック(FormGroupのvalidators)

これは先ほどの matchFieldsValidator() が該当します。ポイントは次です。

  • 単項目のvalidatorではなく グループにぶら下げる
  • エラーキー(例:fieldsMismatch)を グループのerrorsに置く
  • 表示条件は「ユーザーが確認入力に触ったか」を基準にすることが多い

非同期:AsyncValidatorFnの形だけ押さえる

「サーバー問い合わせが必要な検証」は同期で完結しないので、Angularでは AsyncValidatorFn を使います。AsyncValidatorFnは「controlを受け取り、PromiseまたはObservableでエラー(またはnull)を返す関数」として定義されています。8

最小の形だけ示します(中身はダミーです)。

// username-async.validator.ts
import { AbstractControl, AsyncValidatorFn, ValidationErrors } from '@angular/forms';
import { Observable, of } from 'rxjs';
import { delay, map } from 'rxjs/operators';

export function uniqueUsernameValidator(): AsyncValidatorFn {
  return (control: AbstractControl): Observable<ValidationErrors | null> => {
    const value = String(control.value ?? '');

    // 本来はHTTPで重複確認などをします(ここでは疑似的に遅延させます)
    return of(value).pipe(
      delay(300),
      map((v) => (v.toLowerCase() === 'admin' ? { usernameTaken: true } : null))
    );
  };
}

Reactive Formsに付ける例:

import { uniqueUsernameValidator } from './username-async.validator';

email: new FormControl('', {
  nonNullable: true,
  validators: [Validators.required, Validators.email],
  asyncValidators: [uniqueUsernameValidator()],
}),

非同期バリデーションは入力のたびに発火するとAPIが増えがちです。実務では「いつ発火させるか(blurやsubmit)」や「間引き(debounce)」の設計が重要になりやすいです(ここは実装方針によります)。

よくあるハマりどころ(初心者が詰まりやすい順)

  • エラーが出ない
    • invalid だけを条件にしていて、ユーザーが触っていない段階から表示したくないのに表示条件が崩れていることがあります。dirty || touched を併用すると整理しやすいです。5
  • 送信ボタンが押せない理由が分からない
    • form.invalid でボタンを無効化した場合、エラー文が出ていないと「なぜ押せないか」が伝わりません。送信時に markAllAsTouched() で一括表示すると体験が安定します(経験則)。
  • ブラウザ標準のバリデーションUIが割り込む
    • Angular Formsは novalidate を自動付与します。標準UIを使うなら ngNativeValidate を検討してください。2
  • ルールが散らかって管理できない
    • エラーメッセージをテンプレに直書きし続けると増えます。この記事の firstErrorMessage() のように、まずは辞書化して「増えても1か所」を作ると破綻しにくいです(経験則)。

まとめ

Angularのバリデーションは、フォームの作り方(Reactive / Template-driven)を選び、ルールを定義し、状態を見て表示し、送信で確定させるという流れで理解すると、手戻りが減ります。
Reactive Formsは、モデル駆動で状態とルールをTS側に寄せやすく3、型を活かした設計にも寄せやすいので4、初心者が実務に近い形へ進むときの足場になりやすいです。
そして、表示UXは、dirty / touched を条件にすることで5、早すぎるエラー表示を避けつつ、送信時には markAllAsTouched() で漏れなく確認できる形にまとまります。
この順序を守ることで、単純なrequiredだけでなく、複数項目の整合性や非同期チェックまで、同じ考え方で拡張できるようになります。

参照先一覧

  1. Angularにはフォーム入力の扱い方としてReactive FormsとTemplate-driven Formsの2つのアプローチがある Forms in Angular

  2. Angular Formsを使うとnovalidate<form>に自動付与され、標準UIを使う場合はngNativeValidateを付けられる NgForm • Angular 日本語版 2 3

  3. Reactive Formsはモデル駆動アプローチで、検証や動的フォーム作成まで扱う Reactive forms 2

  4. Angular 14以降、Reactive Formsは厳密な型付けがデフォルト Strictly typed reactive forms (Typed Forms) 2

  5. 早すぎるエラー表示を避けるためdirtyまたはtouchedを条件にすることが推奨されている Validate form input 2 3 4

  6. Angularはフォームコントロールで使える組み込みバリデーター(Validators)を提供する Validators

  7. Template-driven formの作成チュートリアルが提供され、入力とバリデーションを扱う Building a template-driven form

  8. AsyncValidatorFnはcontrolを受け取り、PromiseまたはObservableでエラー(またはnull)を返す関数として定義される AsyncValidatorFn

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?