AngularのReactive Formsを使ったフォーム実装まとめ
はじめに
動的フォームを除いた部分で一般的に必要な機能を盛り込んだミニマムなサンプルを作ることができたので、その際に得た知見をまとめておく。タイトルの通り、フォームの実装には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
は子として FormControl
と Cross-field Validator
を持つ。
FormControl
はHTMLの <input>
をViewとするならば、そのModelというべき存在で、<input>
と一対一に対応し入力値を管理する。このとき、各 <input>
に対するバリデーションは FormControl
に Validator
を設定することで実現できる。FormControl
に Validator
が設定されている場合、バリデーションが発火するのは FormControl
に値がセットされるタイミングである。このセットのタイミングは FormControl
の updatedOn
のプロパティで設定することができる。
最後に FormGroup
の子で説明を飛ばした Cross-field Validator
について説明する。これは特定の <input>
と一対一では紐付かない、複数の <input>
を跨いで実行されるバリデーションを定義する。役割から自然なようにこれは FormGroup
の直接の持ち物になる。
Custom Validationの実装
単一のinputに対するCustom Validation
必須チェックや、文字列の長さの最大最小、数値の最大最小のような、基本的なValidatorについては@angular/forms
の Validator
クラスが提供している。それ以外の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
になる。
matchPasswordValidatorexport 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
を返している。
VerifyPasswordFormErrorStateMatcherexport 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>
にセットしてみて欲しい。
引数はフィルタリングしたい文字の配列を渡す。例えば、以下のように使う。
typescriptremoveCharacters = ['-', '^', ',', '.', '\\', '/', ' '];
html<input
matInput
placeholder="ユーザー名"
id="name"
name="name"
formControlName="name"
[appTrimValue]="removeCharacters"
/>
Directiveの実装
TrimValueDirectiveimport {
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
を定義して使うだけでもよいのではないかと思ってそうしている。その場合には、isSubmitted
の true
への書き換えは (ngSubmit)
にバインドした送信のメソッドで行う。また、送信後にフォームが再編集されたら isSubmitted
を false
に更新したいといった要件にも対応することができる。この場合は、FormGroupが持つ valueChanges
というObservableを購読し、そのsubscriber関数の中で false
への更新を行う。