React
redux
Formik

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

これは リクルートライフスタイル Advent Calendar 2018 の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

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

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

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


InnerSearchForm

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

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 />initialValuesonSubmitrender から辛い感じが伝わってきます。上述した3点がそれぞれ実装されています。


  • 今日・明日・土曜・日曜の候補日の算出どこでやろう => render

  • 開始時刻は現在時刻の区切りの良い時間っぽいけどどこに書こうかなー => initialValues

  • 「指定なし」か〜 => onSubmit

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

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

なんとか作れはしますが読みづらいコードができました。

もし検索フォームに変更があった場合、 initialValuesonSubmitrende に書かれたロジックに悩まされそう、という気がします。

また、ロジックがComponentに書き込まれているのもロジックのテストがしづらく嫌な感じです。

これらの処理だけうまく取り出せるとテストもしやすく良さそうです。


Formオブジェクトを作る

GitHub - roronya/advent-calendar-2018

そこでRailsでよく使われるFormオブジェクトのようなものを用意します。

Formオブジェクトの解説は下の記事が詳しいですが、簡単に説明すると、入出力のための前処理などを担当するオブジェクトです。

参考:

Railsアプリケーションでフォームをオブジェクトにして育てる - クックパッド開発者ブログ

[Rails][Reform]Formオブジェクト使い方まとめ - Qiita


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()

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 に持たせてしまします。

このようにしてしまえば普通のクラスなのでテストを書くのも簡単です。

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

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


SearchForm(Container)

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)

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

検索条件を保持したければStoreに SearchCondition をそのまま入れてあげればOKです。

後はいつもどおりReducerを書いて handleSubmit でactionをdispatchします。

コードを張ると長くなるので、詳しくはリポジトリを見てください。


終わりに

BFFを用意できるのであれば、BFFでフロントとやり取りしやすい形式のインタフェースを提供してしまったほうが楽かなーとは思います。

ですが、関心事でオブジェクトにしているので、検索条件保持などは、flattenなpropsを扱うよりも書きやすいかな、と思います。

マサカリ募集中です!コメントください!!

よいお年を〜。