Help us understand the problem. What is going on with this article?

Angular入門 未経験から1ヶ月でサービス作れるようにする その7. Angularのフォーム2 (Reactive forms)

More than 1 year has passed since last update.

前回 は、これまで学んできたことの実践を通してAngularに対する知識を深めました。

本記事では、 [(ngModel)] を利用しないフォーム ( Reactive forms といいます )の書き方を学んでいきます。

  • ReactiveFormの基礎
  • [(ngModel)] をFormGroup, FormControlで書き換える
  • Formの送信による保存処理

をやっていきます。

この記事のソースコード

https://github.com/seteen/AngularGuides/tree/入門その07

AngularのForm要素の基礎

現状の product-edit.component.ts[(ngModel)] を利用して Form の一つ一つの要素を product の各要素と対応付けています。

Angularでは、 Form に対するこのような関連付けの処理を専用で行うためのクラスが用意されています。

それぞれについて説明していきます。

クラス 役割
FormControl <input> などの入力要素一つに対応するクラス
FormGroup <form> 要素に対応するクラス
FormBuilder FormGroup, FormControl を作成するクラス

[(ngModel)] を使った例との対応

現状の product-edit.component.ts との対応を考えると下記のようになります。

クラス 役割 product-edit.component
FormControl <input> などの入力要素一つに対応するクラス product
FormGroup <form> 要素に対応するクラス product.name
FormBuilder FormGroup, FormControl を作成するクラス なし

[(ngModel)]FormGroup , FormControl で書き換える

まずは、 Typescriptから書き換えていきます。

src/app/product/product-edit/product-edit.component.ts
import { Component, OnInit } from '@angular/core';
import { Product } from '../../shared/models/product';
import { ProductService } from '../../shared/services/product.service';
import { ActivatedRoute, Params, Router } from '@angular/router';
import { FormBuilder } from '@angular/forms';

@Component({
  selector: 'app-product-edit',
  templateUrl: './product-edit.component.html',
  styleUrls: ['./product-edit.component.scss']
})
export class ProductEditComponent implements OnInit {
  productForm = this.fb.group({ // <= 変更
    id: [''],
    name: [''],
    price: [''],
    description: [''],
  });

  constructor(
    private route: ActivatedRoute,
    private router: Router,
    private fb: FormBuilder, // <= 追加
    private productService: ProductService,
  ) {}

  ngOnInit() {
    this.route.params.subscribe((params: Params) => {
      this.productService.get(params['id']).subscribe((product: Product) => {
        this.productForm.setValue({ // <= 変更
          id: product.id,
          name: product.name,
          price: product.price,
          description: product.description,
        });
      });
    });
  }

  saveProduct(): void {
    this.router.navigate(['/products']);
  }
}

解説

constructor部分

FormBuilder を追加しています。

変数定義部分

product 変数を消して、 productForm 変数を追加しました。

productForm の型は、 FormGroup です。 this.fb.group()FormGroup を入れています。

このメソッドの引数は、 Form の要素として利用する値のキーとその初期値です(配列になっているのは、2つ目移行にValidatorを入れられるようにするためです。これは後ほど説明します)。

つまり、ここでは、 product と同じ要素(id, name, price, description) を持つ FormGroup を作っています。

なお、この各 name: ['']FormControl です。

FormBuilderを使うとどの要素がどのクラスなのかわかりにくいので、FormBuilderを使わないで同様の処理ができるコードを下記に書いておきます。

productForm = new FormGroup({
  id: new FormControl(''),
  name: new FormControl(''),
  price: new FormControl(''),
  description: new FormControl(''),
});

ngOnInit部分

productService から product を取り出した後に、 productForm に現状の product の値を入れています。

HTMLで FormGroup , FormControl を使う

src/app/product/product-edit/product-edit.component.html
<div class="container">
  <div class="title">商品編集</div>
  <form (ngSubmit)="saveProduct()" [formGroup]="productForm">
    <div class="edit-form">
      <div class="edit-line">
        <label>ID</label>
        <span>{{ productForm.controls.id.value }}</span>
      </div>
      <div class="edit-line">
        <label>名前</label>
        <input type="text" formControlName="name" required>
      </div>
      <div class="edit-line">
        <label>価格</label>
        <input type="number" formControlName="price">
      </div>
      <div class="edit-line">
        <label>説明</label>
        <input type="text" formControlName="description">
      </div>
    </div>
    <div class="footer">
      <span class="button white" [routerLink]="['/products', productForm.controls.id.value]">キャンセル</span>
      <button class="button black">保存</button>
    </div>
  </form>
</div>

<form> 要素に対して [formGroup] を指定することで、 <form> に対して productForm を関連付けています。

また、各 <input> 要素から [(ngModel)] を消して、 formControlName を追加して productForm の各要素と関連付けています。

ID だけは編集するものではないので、 productForm.control.id.value で取り出してそのまま表示するようにしています。

新しいモジュールを追加する

実は、ここで起動してもまたエラーとなります。

compiler.js:215 Uncaught Error: Template parse errors:
Can't bind to 'formGroup' since it isn't a known property of 'form'. ("<div class="container">
  <div class="title">商品編集</div>
  <form (ngSubmit)="saveProduct()" [ERROR ->][formGroup]="productForm">
    <div class="edit-form">
      <div class="edit-line">

これは、また モジュールが足りないという意味です。
その6で紹介した手順で探してみます。

https//angular.io にアクセスして FormGroup を探してみます。

001.png

ここで module で検索。 .... ない!

実は、Angularの公式は、このあたりかなりひどく、わかりにくいです。

実は、この Can't bind to 'xxx' since it isn't a known property of 'yyyy' というエラーですが、 Directive (HTMLで言うところの 属性(attribute) ) がないというエラーです。

上記画像で調べたものは、 実は Directive ではなく、 Class でした。

https://angular.ioFormGroup を検索すると、下記のような画面になります。

002.png

ここで、 C となっているものは Class , D となっているものが Directive です。

FormGroup の下に FormGroupDirective というものがあります。

実は、こちらを調べないといけません。

003.png

FormGroupDirective の画面に行くと、上記の画面に行きます。

ここに

@Directive({
    selector: '[formGroup]',
    providers: [formDirectiveProvider],
    host: { '(submit)': 'onSubmit($event)', '(reset)': 'onReset()' },
    exportAs: 'ngForm'
})

と記載されています。

この Directiveselector[formGroup] になっています。つまり、名前に Directive とついてしまって混乱しましたが、これが調べたかったものです。

ここで module を検索してみます。

004.png

ありましたね。

この [formGroup]ReactiveFormModule に属しています。

app.module.ts に追加します。

src/app/app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppComponent } from './app.component';
import { ProductListComponent } from './product/product-list/product-list.component';
import { ProductDetailComponent } from './product/product-detail/product-detail.component';
import { AppRoutingModule } from './app-routing.module';
import { ProductEditComponent } from './product/product-edit/product-edit.component';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';

@NgModule({
  declarations: [
    AppComponent,
    ProductListComponent,
    ProductDetailComponent,
    ProductEditComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    FormsModule,
    ReactiveFormsModule,
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

この状態で、動作確認してみましょう。

ng serve で起動して、 http://localhost:4200/products/1/edit にアクセスしてみます。

005.png

ちゃんと値が入ってますね。 FormGroup , FormControl がうまく <form> 要素と関連付けられました。

TIPS: 奥が深いForm要素
今回、3つのクラスを紹介しましたが、Angularには Formに関するクラスが他にも用意されています。
詳しくは Angularの公式サイトで調べてみてください。
https://angular.io/guide/reactive-forms

ここまでのコミット

https://github.com/seteen/AngularGuides/commit/a194b5d404828f7a76f68d488983fd8dd251cd9a

Formの送信による保存処理

[(ngModel)]FormGroup , FormControl を用いて置き換えていきました。

現状のこのアプリでは、全ての状態はメモリ上で処理されています。 [(ngModel)] のときには、双方向バインディングであったがために、
そのまま何もしなければ保存されていました。

今回で [(ngModel)] を使わなくなったため、編集しても ProductService が持つ products を変更する処理がないため、変更が保存されません。

ProductServiceupdate() メソッドを追加して、変更を保存するように修正していきます。

src/app/shared/services/product.service.ts
import { Injectable } from '@angular/core';
import { Product } from '../models/product';
import { Observable, of } from 'rxjs/index';

@Injectable({
  providedIn: 'root'
})
export class ProductService {
  products = [
    new Product(1, 'Angular入門書「天地創造の章」', 3800, '神は云った。「Angularあれ」。するとAngularが出来た。'),
    new Product(2, 'Angularを覚えたら、年収も上がって、女の子にももてて、人生が変わりました!', 410, '年収300万のSEが、Angularと出会う。それは、小さな会社の社畜が始めた、最初の抵抗だった。'),
    new Product(3, '異世界転生から始めるAngular生活(1)', 680,
      'スパゲッティの沼でデスマーチ真っ最中の田中。過酷な日々からの現実逃避か彼は、異世界に放り出され、そこでAngularの入門書を拾う。現実逃避でさえ、プログラミングをするしかない彼に待ち受けるのは!?'),
  ];

  constructor() { }

  list(): Observable<Product[]> {
    return of(this.products);
  }

  get(id: number): Observable<Product> {
    return of(this.products[id - 1]);
  }

  update(product: Product): void { // <= 追加
    const index = this.products.findIndex((prd: Product) => prd.id === product.id);
    this.products[index] = product;
  }
}
src/app/product/product-edit/product-edit.component.ts
import { Component, OnInit } from '@angular/core';
import { Product } from '../../shared/models/product';
import { ProductService } from '../../shared/services/product.service';
import { ActivatedRoute, Params, Router } from '@angular/router';
import { FormBuilder } from '@angular/forms';

@Component({
  selector: 'app-product-edit',
  templateUrl: './product-edit.component.html',
  styleUrls: ['./product-edit.component.scss']
})
export class ProductEditComponent implements OnInit {
  productForm = this.fb.group({
    id: [''],
    name: [''],
    price: [''],
    description: [''],
  });

  constructor(
    private route: ActivatedRoute,
    private router: Router,
    private fb: FormBuilder,
    private productService: ProductService,
  ) {}

  ngOnInit() {
    this.route.params.subscribe((params: Params) => {
      this.productService.get(params['id']).subscribe((product: Product) => {
        this.productForm.setValue({
          id: product.id,
          name: product.name,
          price: product.price,
          description: product.description,
        });
      });
    });
  }

  saveProduct(): void {
    const { id, name, price, description } = this.productForm.getRawValue(); // <= 追加
    this.productService.update(new Product(id, name, price, description)); // <= 追加
    this.router.navigate(['/products', this.productForm.controls.id.value]); // <= 変更
  }
}

解説

ProductServiceについて

ProductServiceupdate() メソッドを追加しました。

update(product: Product): void {
  const index = this.products.findIndex((prd: Product) => prd.id === product.id);
  this.products[index] = product;
}

処理としては、updateで与えられた product と同じ id を持つ index を探して、 productsindex 番目の要素を入力された product と入れ替えるというものです。

TIPS: findIndex() メソッド
JavascriptのArrayのメソッドです。Arrayの各要素について、引数のメソッドを実行して、 true が帰ってくる最初の配列のインデックスを返します。
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/findIndex

ProductEditComponentについて

saveProduct() メソッドを変更しました。

saveProduct(): void {
  const { id, name, price, description } = this.productForm.getRawValue(); // <= 追加
  this.productService.update(new Product(id, name, price, description)); // <= 追加
  this.router.navigate(['/products', this.productForm.controls.id.value]); // <= 変更
}

まず最初の行の

const { id, name, price, description } = this.productForm.getRawValue();

について解説します。

this.productForm.getRawValue() では、 FormGroupFormControl の値を key: value の形式でオブジェクトに変換したものを返します。

今回、例えば id3product に対して何も編集せずに実行すると下記を返します。

{id: 3, name: "異世界転生から始めるAngular生活(1)", price: 680, description: "スパゲッティの沼でデスマーチ真っ最中の田中。過酷な日々からの現実逃避か彼は、異世界に放り出され、そこでAngularの入門書を拾う。現実逃避でさえ、プログラミングをするしかない彼に待ち受けるのは!?"}

次に、

const { xxx } = yyy というのは、 yyy オブジェクトの xxx 要素を xxx という変数に入れる、という処理になります。

そのため、 id, name, price, description という変数に FormGroup の各値を入れているということになりますね。

次の行では、 ProductService に新しく作った update() メソッドを呼び出しています。

最後の行は、特に変更する必要はなかったのですが、保存後に一覧画面に遷移するようになっていたので、詳細画面に遷移するように修正しました。

では、動作確認をしてみましょう。

006.gif

ちゃんと保存した内容が反映されるようになりましたね。

ここまでのコミット

https://github.com/seteen/AngularGuides/commit/09075ffc2dfef79d8ac4463c3200dccfabb5e9ed

まとめ

今回は、 [(ngModel)] を使わない Reactive forms について学んでいきました。
Reactive forms は、 [(ngModel)] に比べて少し冗長ですが、次回のバリデーション部分を含めると、こちらもかなり使い勝手が良くなってきます。

次回は、Angularのバリデーションのやり方を解説していきます。

Angular入門 未経験から1ヶ月でサービス作れるようにする その8. バリデーション

入門記事一覧

「Angular入門 未経験から1ヶ月でサービス作れるようにする」は、記事数が多いため、まとめ記事 を作っています。
https://qiita.com/seteen/items/43908e33e08a39612a07

seteen
Angularを広めたいエンジニア Ruby, Angularが好きです。
https://twitter.com/yazumoto
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away