AngularのReactive Formsを使ったフォーム実装まとめ

投稿日: 11/13/2021

はじめに

動的フォームを除いた部分で一般的に必要な機能を盛り込んだミニマムなサンプルを作ることができたので、その際に得た知見をまとめておく。タイトルの通り、フォームの実装にはReactive Formsを使っている。最終的なコードとデモは以下。

https://yukiyokotani.github.io/study-angular/#/form-with-ngx-mask

Reactive Formにおける登場人物

Reactive Formでいくつか部品が出てくるので、概念をまとめておく。登場するのは FormGroup, FormControl, Validator の3つで、親子関係として下図のようになる。

FormGroup
  ├─ FormControl (1)
  |    ├─ Validator (1-i)
  |    └─ Validator (1-ii)
  |
  ├─ FormControl (2)
  |    └─ Validator (2-i)
  |
  └─ Cross-field Validator (1)

まず FormGroup はフォームを司る存在で、HTMLの <form> と対応する。FormGroup は子として FormControlCross-field Validator を持つ。

FormControl はHTMLの <input> をViewとするならば、そのModelというべき存在で、<input> と一対一に対応し入力値を管理する。このとき、各 <input> に対するバリデーションは FormControlValidator を設定することで実現できる。FormControlValidator が設定されている場合、バリデーションが発火するのは FormControl に値がセットされるタイミングである。このセットのタイミングは FormControlupdatedOn のプロパティで設定することができる。

最後に FormGroup の子で説明を飛ばした Cross-field Validator について説明する。これは特定の <input> と一対一では紐付かない、複数の <input> を跨いで実行されるバリデーションを定義する。役割から自然なようにこれは FormGroup の直接の持ち物になる。

Custom Validationの実装

単一のinputに対するCustom Validation

必須チェックや、文字列の長さの最大最小、数値の最大最小のような、基本的なValidatorについては@angular/formsValidator クラスが提供している。それ以外のValidatorを用意したいときに、Validatorの実装が必要になる。

ここでは例として、日付のチェックのValidator実装を載せる。具体的には、FormControlにセットされた文字列が「YYYY/MM/DD のフォーマットになっているか」、「実際に存在する日付かどうか」の2点を判定し、どちらか一方でも不適なら不正な日付としてエラーオブジェクトを返す。

dateValidator
// dayjsを使用している export const dateValidator: ValidatorFn = ( control: AbstractControl ): ValidationErrors | null => { const format = 'YYYY/MM/DD'; const date = control.value; const isValid = typeof date === 'string' && dayjs(date, format).format(format) === date; return isValid ? null : { invalidDate: true }; };

FormControlにセットするためのValidatorなので、引数の control にはFormControlが入るものとして実装する。AngularのValidatorの仕様として返り値の型は ValidatorFn である必要がある。

上のサンプルではエラーがないときの返り値を null にしているが、 実装上 undefined のほうが良いと考えている。というのも、のちのちエラーメッセージの表示制御を行うときに、入力値にエラーがあるかどうかの状態をFormControlから取らないといけないのだが、FormControlのプロパティは至るところがNullableになっており、Optional Chainingした結果が undefined に化ける可能性がある。よって、最後の判定で null でもなく、undefined でもない、みたいなロジックを書かないといけなくなる。(*ngIfに渡すためには boolean に変換する必要がある)

複数のinputにまたがるValidation

Validatorの定義

例としてパスワードを確認用も含めて2回入力させるフォームを考える。このとき両者は一致する必要があるが、このバリデーションは2つの <input> に跨るためCross-field Validationになる。単一の場合と同じくCross-field Validationも返り値の型は ValidatorFn になる。

matchPasswordValidator
export const matchPasswordValidator: ValidatorFn = ( control: AbstractControl ): ValidationErrors | null => { const password = control.get('password'); const verifyPassword = control.get('verifyPassword'); return password && verifyPassword && password.value !== verifyPassword.value ? { notMatchPassword: true } : null; };

FormGroupにセットするValidatorなので、引数の control にはFormGroupが入るものとして実装する。

ErrorStateMatcherの定義

Cross-field Validationにおいて追加で考えないといけないのが、ErrorStateMathcerの実装である。Cross-field Validationはそれだけではどの <input> とも紐付いていないので、エラーになったときにフォームとしてはエラー状態になるが、その原因となっている <input> (より正確には <mat-form-field>)がエラー状態にならない。<mat-form-field> がエラー状態にならないと、 <mat-error> のようなコンポーネントは表面的な *ngIf のロジック等に関わらず、エラーメッセージを表示しない作りになっている。よって、Cross-field Validationの対象となる <input> に対しては自分でErrorStateMathcerを定義して、エラー状態を適切に管理する必要がある。

サンプルは確認用パスワードのフォームに向けたErrorStateMatcherである。大雑把に言うと、「自分のFormControlがエラー状態になっている」もしくは「FormGroupが持つエラーを見て、当該のCross-field Validationがエラーになっている」場合にはエラー状態であることを示す true を返している。

VerifyPasswordFormErrorStateMatcher
export class VerifyPasswordFormErrorStateMatcher implements ErrorStateMatcher { isErrorState( control: FormControl | null, form: FormGroupDirective | NgForm | null ): boolean { const isSubmitted = form && form.submitted; return ( isSubmitted === true && control !== null && (control.errors !== null || (form?.form?.errors?.notMatchPassword ?? false)) ); } }

入力値のフォーマット(フィルタリング)

以下のように入力値に特定のフォーマットを強制したい場合は、ngx-mask を使うと便利である。

  • 日付の入力で 2021/11/13 のように / 区切りのフォーマットを強制したい場合
  • 数値の入力で表示上は 1,000,000 のように , (いわゆるthousands separator) を表示したい場合

ngx-mask はオプションで入力中にplaceholderを表示するかどうかや、最終的にFormControlにセットする値にフォーマットのための文字 (/,) を含めるかどうかも設定することができる。

また、値のフィルタリングも簡単に実装することができる。

入力値のトリミング・フィルタリング(独自実装)

少し別の視点で、独自に入力値をトリミング、フィルタリングしたい場合のDirectiveの実装について書く。ただ、この内容は ngx-mask を利用していれば基本的に不要な話ではある。

このあと実装をベタッと貼り長くなるため、先に説明する。FormControlが <input> にアクセスするために、VlueAccessorというクラスを経由している。無意識でフォーム実装しているうちは、基本的にValueAccessorはデフォルトのものが使われているが、一部Angular MaterialのDatePickerなんかはVlueAccessorを独自にProvideしていたりする。

このDirectiveは <input> のレイヤーでカスタマイズしたValueAccessorをProvideし、FormControlが <input> にアクセスする処理に介入する。

知っておくべきことは3つで、

  • DefaultValueAccessorを継承して実装する
  • View → Model方向の更新は 'change', 'blur' イベントをトリガーに行い、superクラス (DefaultValueAccessor) の onChange, onTouched を利用する
  • Model → View方向の更新はsuperクラスの writeValue をオーバーライドして定義する

興味があればコピーして <input> にセットしてみて欲しい。
引数はフィルタリングしたい文字の配列を渡す。例えば、以下のように使う。

typescript
removeCharacters = ['-', '^', ',', '.', '\\', '/', ' '];
html
<input matInput placeholder="ユーザー名" id="name" name="name" formControlName="name" [appTrimValue]="removeCharacters" />

Directiveの実装

TrimValueDirective
import { Directive, HostListener, forwardRef, Provider, Input, } from '@angular/core'; import { DefaultValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; const TRIM_VALUE_ACCESSOR: Provider = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TrimValueDirective), multi: true, }; /** * テキスト入力フォームの入力値をblur時にトリム・フィルタリングするためのDirective */ @Directive({ selector: `[appTrimValue]`, providers: [TRIM_VALUE_ACCESSOR], }) export class TrimValueDirective extends DefaultValueAccessor { private needEscapeCharacters = [ '/', '\\', "'", '"', '.', '*', '+', '?', '^', '$', '-', '|', '(', ')', '{', '}', '{', '}', ]; /** filterしたいパターンのリスト */ private _filterPattern?: RegExp; @Input() set appTrimCustomValue(trimPattern: string[]) { this._filterPattern = new RegExp( `(${trimPattern.reduce((prev, curr) => { const escapedCurr = this.needEscapeCharacters.includes(curr) ? '\\' + curr : curr; return `${prev}|${escapedCurr}`; }, '')})+`, 'g' ); } @HostListener('input', ['$event.target.value']) ngOnChange = (val: string) => { let filteredVal = val; if (this._filterPattern) { filteredVal = filteredVal.replace(this._filterPattern, ''); } this.onChange(filteredVal.trim()); }; @HostListener('blur', ['$event.target.value']) ngOnBlur = (val: string) => { let filteredVal = val; if (this._filterPattern) { filteredVal = filteredVal.replace(this._filterPattern, ''); } this.writeValue(filteredVal.trim()); this.onTouched(); }; writeValue(value: any): void { if (typeof value === 'string') { value = value.trim(); } super.writeValue(value); } }

上記Directive実装の参考:

送信済みフラグ

最後にフォームの送信済みフラグの実装について書く。AngularのAbstractControl(FormGroup, FormControlの基底クラス)は touched (フォーカスされたかどうか)、dirty(編集されたかどうか)のフラグは持っているものの、フォームが送信されたかどうかのフラグを持っていない。しかし、フォームの実装上、エラーメッセージの表示条件などで初期値が false でフォームが送信されたときに true になるようなフラグが欲しい場合がある。このようなフラグはテンプレートの <form> タグにテンプレート変数を埋め込んで参照を持ち(#ngForm="ngForm")、ngForm.submitted を見る方法が一般的なようである。個人的には formGroup に加えて ngForm<form> に渡すのが参照関係を複雑化させているようで好きではなかったので、単純にComponentにboolean型のメンバー変数、例えば isSubmitted を定義して使うだけでもよいのではないかと思ってそうしている。その場合には、isSubmittedtrue への書き換えは (ngSubmit) にバインドした送信のメソッドで行う。また、送信後にフォームが再編集されたら isSubmittedfalse に更新したいといった要件にも対応することができる。この場合は、FormGroupが持つ valueChanges というObservableを購読し、そのsubscriber関数の中で false への更新を行う。