ReactでForm作るの辛い問題を何とかしたい

これは [リクルートライフスタイル Advent Calendar 2018](https://qiita.com/advent-calendar/2018/recruitlifestyle) の12日目の記事です。

こんにちは！リクルートライフスタイルでエンジニアをやっている @roronya です。
ここ半年ほどReactでアプリケーションを書いていました。
噂通りFormで苦しみましたが、ReactにもRailsのFormオブジェクトのようなものを導入してみるとスッキリしたので、そのことについて書きます。

# Formの辛いところ
Form作るのは辛いです。どのへんが辛いかというと、この4つくらいかなと思います。

- Formのために作り込まなければならないものが多い
  - `<select>` で表示する選択候補とか
  - フォーム用に `{label: "hoge", :value: "hoge"}` の形に変換する処理とか
- Formに入力された値をアプリケーションとして持ちたい形に変換しなければならない
  - 検索フォームに入れた結果を加工してstateに入れて保持したいとか
- Formに入力された値をもとにAPIを叩く場合、API用に変換しなければならない
  - camelCaseをsnake_caseに変換するとか
  - APIで要求されているインタフェースに合わせるとか
- ↑のような処理を書いておく適した場所がない

抽象的なので、具体的に考えてみます。

# HotPepperBeautyの検索フォームを実際に作ってみる
![29a3f890-11aa-4008-8c64-0494039da2f1.png](https://qiita-image-store.s3.amazonaws.com/0/27893/9ee4833b-b2bc-756b-6921-e6febc698833.png)

PC版のHotPepperBeautyの「日付からサロンを探す検索フォーム」が例として良い感じなのでこれを実装してみます。

たったこれだけのフォームでもフォームあるあるの箇所が3つくらい見えていて、「あ〜若干嫌ダナー」という感じがします。

- 今日・明日・土曜・日曜の候補日の算出どこでやろう
- 開始時刻は現在時刻の区切りの良い時間っぽいけどどこに書こうかなー
- 「指定なし」か〜

こういう仕様だとします。（※例です。実際の仕様とは異なります！）

- 初期条件
  - 日付: 今日
  - 開始時刻の始まり: 直近の時刻
  - 開始時刻の終わり: 指定なし
- 日付の候補は今日・明日と次の土日
- 「指定した条件で探す」を押すとAPIを叩く
- APIは以下のパラメータをGETで送る
  - date: 日付
  - dateFrom: 開始時刻の始まり 送らなければ「指定なし」になる
  - dateTo: 開始時刻の終わり 送らなければ「指定なし」になる

なので画像のような条件で検索するときは
 `https://api.beauty.hotpepper.jp/search?date=12/9&dateFrom=11:00` 
にGETするイメージです。「指定なし」の `dateTo` はパラメタに付ません。

## 素直に作ってみる
コードはGitHubにあげています。各例ごとにブランチを切っています。

[GitHub - roronya/advent-calendar-2018 at feature/naive](https://github.com/roronya/advent-calendar-2018/tree/feature/naive)

記事では一部抜粋します。

Formikを気に入って使っているので、この例でもFormikを使います。

### InnerSearchForm
まず `<Formik />` の `render` に渡す `InnnerSearchForm` コンポーネントはこんな感じになります。普通のFormですね。

```javascript
import React from "react";

export default ({ values, candidates, handleChange, handleSubmit }) => (
  <form onSubmit={handleSubmit}>
    <div style={{ display: "flex", justifyContent: "center" }}>
      {candidates.map(c => (
        <div key={`date${c.value}`}>
          <input
            type="radio"
            id={c.value}
            name="date"
            defaultChecked={values.date === c.value}
            value={c.value}
            onChange={handleChange}
          />
          <label htmlFor={c.value}>{c.label}</label>
        </div>
      ))}
    </div>
    <div>
      <label>開始時刻</label>
      <select value={values.timeFrom} name="timeFrom" onChange={handleChange}>
        <option value="null">指定なし</option>
        {[...Array(24).keys()].map(t => (
          <option key={`dateFrom${t}`} value={`${t}:00`}>
            {t}:00
          </option>
        ))}
      </select>
      ~
      <select value={values.timeTo} name="timeTo" onChange={handleChange}>
        <option value="null">指定なし</option>
        {[...Array(24).keys()].map(t => (
          <option key={`dateTo${t}`} value={`${t}:00`}>
            {t}:00
          </option>
        ))}
      </select>
    </div>
    <input type="submit" value="指定した条件で探す" />
  </form>
);
```

### SearchForm

ここからが問題のもろもろの変換処理です。 `<Formik />` の`initialValues` や `onSubmit` 、 `render` から辛い感じが伝わってきます。上述した3点がそれぞれ実装されています。

- 今日・明日・土曜・日曜の候補日の算出どこでやろう  => `render`
- 開始時刻は現在時刻の区切りの良い時間っぽいけどどこに書こうかなー => `initialValues`
- 「指定なし」か〜 => `onSubmit`

（日付の処理はMoment.js使えって言われそうだけどとりあえずナイーブに…）

```javascript
import React from "react";
import { Formik } from "formik";
import InnerForm from "./InnerSearchForm";

export default () => (
  <Formik
    initialValues={(() => {
      // 開始時刻は現在時刻の区切りの良い時間
      const now = new Date();
      const month = now.getMonth() + 1;
      const day = now.getDate();
      const date = `${month}/${day}`;
      const hour = now.getHours() + 1;
      return {
        date: date,
        timeFrom: `${hour}:00`,
        timeTo: "null"
      };
    })()}
    onSubmit={values => {
      // 「指定なし」か〜
      const params = {
        date: values.date,
        time_from: values.timeFrom === "null" ? null : values.timeFrom,
        time_to: values.timeTo === "null" ? null : values.timeTo
      };
      // APIを叩くActionCreatorを呼び出すべきだが、簡単のためalertする
      // axios.get(endpoint, {params: params})
      alert(JSON.stringify(params));
    }}
    render={({ values, handleSubmit, handleChange }) => (
      <InnerForm
        values={values}
        candidates={(() => {
          // 今日・明日・土曜・日曜の候補日の算出
          let now = new Date();
          const today = `${now.getMonth() + 1}/${now.getDate()}`;
          now.setDate(now.getDate() + 1);
          const tomorrow = `${now.getMonth() + 1}/${now.getDate()}`;
          while (now.getDay() !== 6) {
            now.setDate(now.getDate() + 1);
          }
          const sut = `${now.getMonth() + 1}/${now.getDate()}`;
          now.setDate(now.getDate() + 1);
          const sun = `${now.getMonth() + 1}/${now.getDate()}`;
          return [
            { label: `今日${today}`, value: `${today}` },
            { label: `明日${tomorrow}`, value: `${tomorrow}` },
            { label: `土曜${sut}`, value: `${sut}` },
            { label: `日曜${sun}`, value: `${sun}` }
          ];
        })()}
        handleSubmit={handleSubmit}
        handleChange={handleChange}
      />
    )}
  />
);
```

レンダリングするとこんな感じのFormになります。

![3e2c784b-d84b-4219-8dda-31b6053717e9.png](https://qiita-image-store.s3.amazonaws.com/0/27893/99ad35b7-1272-8b0c-f7a4-5577058cad4a.png)

なんとか作れはしますが読みづらいコードができました。
もし検索フォームに変更があった場合、 `initialValues` ・ `onSubmit` ・ `rende` に書かれたロジックに悩まされそう、という気がします。
また、ロジックがComponentに書き込まれているのもロジックのテストがしづらく嫌な感じです。
これらの処理だけうまく取り出せるとテストもしやすく良さそうです。

## Formオブジェクトを作る
[GitHub - roronya/advent-calendar-2018](https://github.com/roronya/advent-calendar-2018/tree/master)

そこでRailsでよく使われるFormオブジェクトのようなものを用意します。
Formオブジェクトの解説は下の記事が詳しいですが、簡単に説明すると、入出力のための前処理などを担当するオブジェクトです。

参考:
[Railsアプリケーションでフォームをオブジェクトにして育てる - クックパッド開発者ブログ](https://techlife.cookpad.com/entry/2018/05/30/084420)
[[Rails][Reform]Formオブジェクト使い方まとめ - Qiita](https://qiita.com/akichim21/items/afe961ea752f894b389b)

### SearchForm
<https://github.com/roronya/advent-calendar-2018/tree/master>
というわけで、Formオブジェクトを使って、検索条件に関わる処理をカプセル化して、`SearchForm` をこんな見た目にしたいです。

- `initialValue` での検索条件の初期化とForm用の変換 => `new SearchCondition().toForm()`
- `render` での候補日の算出 => `SearchCondition.getCandidates(new Date())`
- `onSubmit` でのAPIのための変換 => `SearchCondition.fromForm(values)` で一度 `SearchCondition` インスタンスにしてから `.toAPI()`

```javascript
import React from "react";
import { Formik } from "formik";
import InnerForm from "./InnerSearchForm";
import SearchCondition from "./SearchCondition";

export default () => (
  <Formik
    initialValues={new SearchCondition().toForm()}
    onSubmit={values => {
      const params = SearchCondition.fromForm(values).toAPI();
      // APIを叩くActionCreatorを呼び出すべきだが、簡単のためalertする
      // axios.get(endpoint, {params: params})
      alert(JSON.stringify(params));
    }}
    render={({ values, handleSubmit, handleChange }) => (
      <InnerForm
        values={values}
        candidates={SearchCondition.getCandidates(new Date())}
        handleSubmit={handleSubmit}
        handleChange={handleChange}
      />
    )}
  />
);
```

スッキリしました。

### SearchCondition
この `SearchCondition` クラスがFormオブジェクトです。
Form用のデータの加工処理は `SearchCondition` に持たせてしまします。
このようにしてしまえば普通のクラスなのでテストを書くのも簡単です。

```javascript
const toDateString = date => {
  const month = date.getMonth() + 1;
  const day = date.getDate();
  return `${month}/${day}`;
};

export default class SearchCondition {
  constructor(date = new Date(), timeFrom = null, timeTo = null) {
    this.date = date;
    this.timeFrom = timeFrom;
    this.timeTo = timeTo;
  }
  toForm() {
    return {
      date: toDateString(this.date),
      timeFrom: `${this.date.getHours() + 1}:00`,
      timeTo: this.timeTo ? `${this.timeTo}:00` : "null"
    };
  }
  toAPI() {
    return {
      date: toDateString(this.date),
      time_from: this.timeFrom,
      time_to: this.timeTo
    };
  }
  static fromForm({ date, timeFrom, timeTo }) {
    return new SearchCondition(
      new Date(date),
      timeFrom === "null" ? null : timeFrom,
      timeTo === "null" ? null : timeTo
    );
  }
  static getCandidates(inputDate) {
    let date = new Date(inputDate);
    const today = `${date.getMonth() + 1}/${date.getDate()}`;
    date.setDate(date.getDate() + 1);
    const tomorrow = `${date.getMonth() + 1}/${date.getDate()}`;
    while (date.getDay() !== 6) {
      date.setDate(date.getDate() + 1);
    }
    const sut = `${date.getMonth() + 1}/${date.getDate()}`;
    date.setDate(date.getDate() + 1);
    const sun = `${date.getMonth() + 1}/${date.getDate()}`;
    return [
      { label: `今日${today}`, value: `${today}` },
      { label: `明日${tomorrow}`, value: `${tomorrow}` },
      { label: `土曜${sut}`, value: `${sut}` },
      { label: `日曜${sun}`, value: `${sun}` }
    ];
  }
}
```

## Reduxを使っているとき
[GitHub - roronya/advent-calendar-2018 at feature/redux](https://github.com/roronya/advent-calendar-2018/tree/feature/redux)

Reduxを使っている場合はこんな見た目になります。かなりシンプルでいい感じに見えます。

### SearchForm(Container)
```javascript
import { connect } from "react-redux";
import SearchForm from "../components/SearchForm";
import SearchCondition from "../forms/SearchCondition";

const mapStateToProps = state => {
  const searchCondition = new SearchCondition();
  return {
    searchCondition: searchCondition.toForm(),
    candidates: SearchCondition.getCandidates(new Date())
  };
};
const mapDispatchToProps = dispatch => ({
  handleSubmit(values) {
    const params = SearchCondition.fromForm(values).toAPI();
    // APIを叩くActionCreatorを呼び出すべきだが、簡単のためalertする
    // axios.get(endpoint, {params: params})
    alert(JSON.stringify(params));
  }
});
export default connect(
  mapStateToProps,
  mapDispatchToProps
)(SearchForm);
```

### SearchForm(Component)
```javascript
import React from "react";
import { Formik } from "formik";
import InnerForm from "./InnerSearchForm";

export default ({ searchCondition, candidates, handleSubmit }) => (
  <Formik
    initialValues={searchCondition}
    onSubmit={values => {
      handleSubmit(values);
    }}
    render={({ values, handleSubmit, handleChange }) => (
      <InnerForm
        values={values}
        candidates={candidates}
        handleSubmit={handleSubmit}
        handleChange={handleChange}
      />
    )}
  />
);
```

### 検索条件を保持したい
[GitHub - roronya/advent-calendar-2018 at feature/save-condition](https://github.com/roronya/advent-calendar-2018/tree/feature/save-condition)

検索条件を保持したければStoreに `SearchCondition` をそのまま入れてあげればOKです。
後はいつもどおりReducerを書いて `handleSubmit` でactionをdispatchします。
コードを張ると長くなるので、詳しくはリポジトリを見てください。

# 終わりに
BFFを用意できるのであれば、BFFでフロントとやり取りしやすい形式のインタフェースを提供してしまったほうが楽かなーとは思います。
ですが、関心事でオブジェクトにしているので、検索条件保持などは、flattenなpropsを扱うよりも書きやすいかな、と思います。
マサカリ募集中です！コメントください！！

よいお年を〜。