はじめに
今回は再利用性や保守性をより高めるため、各フォーム項目をコンポーネントとして切り出すことをしていきます。
経緯
以前自分がこちらの記事を投稿し、現場の新規サービスにもこのやり方を取り入れたのですがこれが後々問題になりました…😭
内容としては
- 条件分岐をうまくやらないと同じ文言が複数表示されてしまうため、条件が複雑になりがちである。
- 共通化してしまっているのでバリデーションが増えるとほかのフォームのテストもしなければいけなくなる。
この2点が問題になりました。
そこで考えた結果、各フォームをコンポーネントに切り出せば幸せになるのではということで今の形に落ち着きました。
では実際に切り出していきます。
前提事項
フォームのデザインはAngular Materialを使用します。これの設定等は既に済んでいる前提で進めていきます。
またリアクティブフォームでの実装をしていきます。
フォルダ構成

実装
`app.module.ts`
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MatButtonModule, MatFormFieldModule, MatInputModule, MatListModule } from '@angular/material';
import { AppComponent } from './app.component';
import { UserIdComponent } from './form-components/user-id/user-id.component';
import { PasswordComponent } from './form-components/password/password.component';
import { UserNameComponent } from './form-components/user-name/user-name.component';
@NgModule({
declarations: [
AppComponent,
UserIdComponent,
PasswordComponent,
UserNameComponent
],
imports: [
BrowserModule,
ReactiveFormsModule,
BrowserAnimationsModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule,
MatListModule,
],
providers: [],
bootstrap: [ AppComponent ]
})
export class AppModule {
}
`base-form.ts`
ErrorStateMatcherの例から必要な箇所だけをとっただけのbase-form
クラスになります。
import { ErrorStateMatcher } from "@angular/material";
import { AbstractControl } from "@angular/forms";
export class MyErrorStateMatcher implements ErrorStateMatcher {
isErrorState(control: AbstractControl | null): boolean {
return !!(control && control.invalid && (control.dirty || control.touched));
}
}
export class BaseForm {
matcher = new MyErrorStateMatcher();
}
`user-id`フォームコンポーネント
コンポーネント生成時にこのフォームコンポーネント自身を親コンポーネントにemit
します。
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
import { BaseForm } from "../base-form";
import { AbstractControl, FormControl, Validators } from "@angular/forms";
@Component({
selector: 'user-id',
templateUrl: './user-id.component.html',
styleUrls: [ './user-id.component.css' ]
})
// ErrorStateMatcherを使用するためBaseFormを継承
export class UserIdComponent extends BaseForm implements OnInit {
// 親コンポーネントにこのフォームコンポーネントをemitするための@Output()
@Output() formReady = new EventEmitter<AbstractControl>();
// フォームの定義
form: AbstractControl = new FormControl('', [ Validators.required ]);
constructor() {
super();
}
// コンポーネント生成時に親コンポーネントへフォームコンポーネントをemit
ngOnInit() {
this.formReady.emit(this.form);
}
}
ビュー側ではAngular Materialに沿って実装するだけです。
[formControle]
にコンポーネントクラスで宣言したFormControl
を指定します。
[errorStateMatcher]
にはbase-form.ts
のMatcher
が適用されます。
あとはバリデーションメッセージを実装するだけです。
<mat-form-field appearance="outline">
<mat-label>ユーザーID</mat-label>
<input matInput placeholder="" [formControl]="form" [errorStateMatcher]="matcher">
<mat-error *ngIf="form.hasError('required')">
必須項目です。
</mat-error>
</mat-form-field>
各フォームを配置する親コンポーネント
親コンポーネント側ではFormGroup
の宣言とそのFormGroup
に各フォームコンポーネントをセットするためのformInitialized()
を実装します。これをビュー側から呼んであげるだけで各フォームコンポーネントがセットされていきます。
値は
this.FormGroup.get('key').value
で取得することができます。
import { Component } from '@angular/core';
import { AbstractControl, FormBuilder, FormGroup } from "@angular/forms";
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: [ './app.component.css' ]
})
export class AppComponent{
// 各フォームコンポーネントをセットするためのFormGroup
myForm: FormGroup;
// 初期化時にFormGroupをインスタンス化
constructor(private fb: FormBuilder) {
this.myForm = this.fb.group({});
}
// 子コンポーネントから受け取ったフォームを親のFormGroupに追加
formInitialized(name: string, form: AbstractControl) {
this.myForm.setControl(name, form);
}
showValue() {
// 値はthis.formGroup.get('key').valueで取得することができる。
console.log(`ユーザーID ... ${this.myForm.get('userId').value}`);
console.log(`ユーザーネーム ... ${this.myForm.get('userName').value}`);
console.log(`パスワード ... ${this.myForm.get('password').value}`);
}
}
ビュー側では子コンポーネントから@Output()
によってemit
された値を受け取り、親コンポーネントのformInitialized()
にkey
とともに渡します。これだけで親コンポーネントのFormGroup
にセットされます。
※user-name
, password
もuser-id
とほぼ同じの実装
<div id="home-container">
<form [formGroup]="myForm" (ngSubmit)="showValue()">
<mat-list>
<mat-list-item>
会員登録
</mat-list-item>
<mat-list-item>
<user-id (formReady)="formInitialized('userId', $event)"></user-id>
</mat-list-item>
<mat-list-item>
<user-name (formReady)="formInitialized('userName', $event)"></user-name>
</mat-list-item>
<mat-list-item>
<password (formReady)="formInitialized('password', $event)"></password>
</mat-list-item>
<mat-list-item>
<button type="submit" mat-raised-button color="primary">登録</button>
</mat-list-item>
</mat-list>
</form>
</div>
動作確認

まとめ
このようにコンポーネントとして切り出すことで、フォームごとに挙動を変えても他のフォームにほぼ影響が出なくなります。
また一つのフォームコンポーネントを修正するだけで、他に使われている箇所まで反映されるので実装がとても楽になります。
おわりに
Angularすこだ…。