1. 結論(この記事で得られること)
Angular の async validator、一見シンプルに見えて意外とハマるポイントが多いです。私も昔、ユーザー名の重複チェックを実装した際に「なぜか連打されてサーバーが悲鳴を上げる」という事故を起こしました。
この記事では以下を明確にします:
- debounce や distinctUntilChanged を正しく組み込む実装
- キャンセル制御と pending 状態の扱い方
- テスト可能な設計とモック戦略
- AI を使った問題切り分けの実践例(有料パート)
- 運用で遭遇する失敗パターンと対策(有料パート)
コピペで動くコード + 設計理由 + テスト戦略をセットで提供するので、明日からのレビューやリファクタに即使えます。
2. 前提(環境・読者層)
- Angular 14 以降(Standalone API を使わない従来の module ベースでも OK)
- RxJS 7 以降
- TypeScript 4.8+
- 想定読者:Reactive Forms は書けるが、async validator で一度は困った経験がある方
検証環境例:
{
"@angular/core": "^17.0.0",
"@angular/forms": "^17.0.0",
"rxjs": "^7.8.0"
}
3. Before:よくあるつまずきポイント
3-1. 連打問題(debounce なし)
// ❌ ダメな例:入力のたびに API が叩かれる
export function usernameValidator(http: HttpClient): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
return http.get(`/api/check-username?name=${control.value}`).pipe(
map((res: any) => res.exists ? { usernameTaken: true } : null)
);
};
}
何が起きるか:
- ユーザーが「t」「ta」「tar」「taro」と入力すると 4 回 API が飛ぶ
- サーバー負荷増、レスポンスの順序逆転でバグの温床
3-2. キャンセル漏れ
// ❌ 前のリクエストがキャンセルされない
return http.get(...).pipe(
delay(1000), // わざと遅延させると順序問題が顕在化
map(...)
);
古いリクエストが後から返ってきて、最新の入力値と食い違う状態になります。
3-3. pending 状態の誤解
<!-- ❌ これだけでは不十分 -->
<div *ngIf="form.get('username')?.pending">チェック中...</div>
初回フォーカス時や debounce 中は pending にならないため、UX が微妙になります。
4. After:基本的な解決パターン
4-1. 正しい async validator の骨格
import { Injectable } from '@angular/core';
import { AbstractControl, AsyncValidatorFn, ValidationErrors } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { Observable, of, timer } from 'rxjs';
import { map, catchError, switchMap, distinctUntilChanged, debounceTime } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
export class UsernameValidatorService {
constructor(private http: HttpClient) {}
validate(): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
// 空値は同期バリデーターに任せる
if (!control.value) {
return of(null);
}
return timer(500).pipe( // debounce 代わり
switchMap(() =>
this.http.get<{ exists: boolean }>(`/api/check-username`, {
params: { name: control.value }
})
),
map(res => res.exists ? { usernameTaken: true } : null),
catchError(() => of(null)) // エラー時は valid 扱い(運用判断)
);
};
}
}
設計ポイント:
- 「timer(500)」 で debounce 相当を実現(updateOn: 'blur' と組み合わせる手もある)
- 「switchMap」 で古いリクエストを自動キャンセル
- 「catchError」 でネットワークエラー時の挙動を明示
4-2. FormControl への適用
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { UsernameValidatorService } from './username-validator.service';
@Component({
selector: 'app-signup',
template: `
<form [formGroup]="form">
<input formControlName="username" placeholder="ユーザー名" />
<div *ngIf="username?.pending" class="spinner">確認中...</div>
<div *ngIf="username?.hasError('usernameTaken')" class="error">
このユーザー名は既に使用されています
</div>
</form>
`
})
export class SignupComponent implements OnInit {
form!: FormGroup;
constructor(
private fb: FormBuilder,
private usernameValidator: UsernameValidatorService
) {}
ngOnInit() {
this.form = this.fb.group({
username: [
'',
[Validators.required, Validators.minLength(3)],
[this.usernameValidator.validate()] // 第三引数に async validator
]
});
}
get username() {
return this.form.get('username');
}
}
4-3. updateOn: 'blur' の活用
this.form = this.fb.group({
username: [
'',
[Validators.required],
[this.usernameValidator.validate()]
]
}, { updateOn: 'blur' }); // フォーカスアウト時だけ検証
入力中に API を叩きたくない場合はこれが最速解。ただし UX とのトレードオフです。