reactjs
redux
redux-form

OPENLOGI AdventCalendar 20日目担当の細川です。OPENLOGIでは主に荷主の方(EC事業者などオープンロジの倉庫に商品を預けていただいている方)向けの機能開発担当しています。

現在の荷主の方向け、倉庫スタッフの方向けシステムは双方フロント側を react+redux で実装してまして、その上でフォーム画面では redux-form を使ってます。実装をスタートさせた時点ではこのライブラリもまだできたばかりで、機能も少なかったのですが、その後勢い良く充実していきまして、バージョンも上がってパフォーマンスの問題も改善し、一通りやりたいことはできるようになってます。現在でもまだまだアクティブに更新されてますので、構成が同様であれば使ってみることをおすすめいたします。

今回はよくお目にかかるフォーム画面の基本パターンを redux-form 上で具体的にどのように実装するのか書いてみたいと思います。

環境

まずは create-react-app 使って簡易にreactの環境を作ります

npm install -g create-react-app

create-react-app redux-form-sample
cd redux-form-sample/
yarn start

ブラウザでデフォルトの画面が立ち上がるかと思います。

この状態では redux-form 等は入ってませんのでインストールします。画面の体裁のために react-bootstrap も入れておきます。

yarn add redux react-redux redux-form react-bootstrap bootstrap@3

次に、生成されたコードに対して、reduxとredux-form動作のための記述を追記します。

src/index.js
import React from "react";
import ReactDOM from "react-dom";
import { Provider } from "react-redux";
import App from './App';
import store from "./store";
import registerServiceWorker from './registerServiceWorker';
import 'bootstrap/dist/css/bootstrap.css';
import 'bootstrap/dist/css/bootstrap-theme.css';

const rootEl = document.getElementById("root");

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  rootEl
);

registerServiceWorker();

redux の reducer 定義に redux-form で使用する領域の記載を追加します。

src/store.js
import { createStore, combineReducers } from 'redux';
import { reducer as reduxFormReducer } from 'redux-form';

const reducer = combineReducers({
  form: reduxFormReducer,
});
const store = createStore(reducer);

export default store;

ではフォーム画面作ってみましょう

基本形

まず、既に生成されている App.js を少し書き換えてから、Form画面実装します。

src/App.js
import React, { Component } from 'react';
import logo from './logo.svg';
import './App.css';
import showResults from "./showResults";
import MyForm from "./MyForm";
import {
  Grid,
  Row,
  Col,
} from 'react-bootstrap';

class App extends Component {
  render() {
    return (
      <div>
        <header className="App App-header">
          <img src={logo} className="App-logo" alt="logo" />
          <h1 className="App-title">Welcome to React</h1>
        </header>
        <Grid style={{padding: 15}}>
          <Row>
            <Col sm={10}>
              <h3>フォーム画面</h3>
              <MyForm onSubmit={showResults}/>
            </Col>
          </Row>
        </Grid>
      </div>
    );
  }
}

export default App;
src/MyForm.js
import React from 'react';
import { Field, reduxForm } from 'redux-form';
import {
  Form,
  FormGroup,
  Col,
  ControlLabel,
  Button,
  ButtonToolbar,
} from 'react-bootstrap';

const MyForm = props => {
  const { handleSubmit, pristine, reset, submitting } = props;
  return (
    <Form horizontal onSubmit={handleSubmit}>
      <FormGroup controlId={'name'}>
        <Col componentClass={ControlLabel} sm={2}>お名前</Col>
        <Col sm={5}>
          <Field
            id={'name'}
            name="name"
            component="input"
            type="text"
            placeholder="Name"
            className={'form-control'}
          />
        </Col>
      </FormGroup>
      <FormGroup>
        <Col smOffset={2} sm={5}>
          <ButtonToolbar>
            <Button bsStyle={'primary'} type="submit" disabled={pristine || submitting}>登録</Button>
            <Button type="button" disabled={pristine || submitting} onClick={reset}>クリア</Button>
          </ButtonToolbar>
        </Col>
      </FormGroup>
    </Form>
  );
};

export default reduxForm({
  form: 'myForm',
})(MyForm);

submit時のサーバーとのリクエスト、レスポンスをエミュレートするための関数を書いておきます。

src/showResults.js
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

export default (async function showResults(values) {
  await sleep(500);
  window.alert(`You submitted:\n\n${JSON.stringify(values, null, 2)}`);
});

まず、フォームにしたいコンポーネントを reduxForm 関数使って修飾します。これによって指定したコンポーネントの入力フィールドがreduxのstoreに格納され、redux-form が提供する各種関数がコンポーネントの props として設定されます。

export default reduxForm({
  form: 'myForm',
})(MyForm);

入力値をredux-formの store領域へ格納するには、このreduxFormによって修飾されたコンポーネント内で、Fieldコンポーネント等を使うことで実現します。

<Field
  id={'name'}
  name="name"
  component="input"
  type="text"
  placeholder="Name"
  className={'form-control'}
/>

ここでcomponent="input"の箇所で自前のカスタムコンポーネントの指定も可能ですが、簡易にテキストボックス等を用意したい場合にこのようにコンポーネントではなく文字列指定で定義することが可能になってます。

ここまで書いて適当な文字列をテキストボックスに入力後、登録ボタンを押下してみると

スクリーンショット 2017-12-16 9.41.33.png

こんな感じのダイアログが上がります。これは

<MyForm onSubmit={showResults}/>

onSubmitに指定されている関数の実行結果ですが、これはMyFormコンポーネントの

<Form horizontal onSubmit={handleSubmit}>

を経由して、指定した関数の引数にreduxのstoreに格納された各フィールド値が入ったオブジェクトをredux-formが渡してくれます。ここでのnameは、Fieldコンポーネントでname属性で指定した名称がkeyとなってます。後程説明しますが、これは階層構造を持つことも可能です。

入力チェックしてみる

redux-form では redux の store に格納された入力値に対してvalidationかける仕掛けが用意されてまして、reduxFormのパラメータとしてvalidateの名称で関数指定をします。

src/MyForm.js
export default reduxForm({
  form: 'myForm',
  validate: myValidation, //<--追加
})(MyForm);

この関数は、submit時に渡されるのと同じ構造の入力値オブジェクトを引数にとって、それぞれのフィールド名に対するエラーメッセージを持つオブジェクトを返す関数になります。

src/SimpleForm.js
const myValidation = values => {
  const errors = {}
  if (!values.name) {
    errors.name = '必須項目です!'
  } else if (values.name.length > 10) {
    errors.name = '10文字以内で指定してください'
  }
  return errors
}

このチェック結果をどこで受け取るかですが、Fieldコンポーネントでcomponentで指定されたコンポーネント/関数に渡されることになります。そこで、ここの実装を少し変えます。

まずフィールド描画をFormGroup単位にしてエラー発生にbootstrapのスタイル指定ができるようにします。

src/MyForm.js
const renderField =
  ({
     input,
     label,
     type,
     placeholder,
     meta: {touched, error, warning}
   }) => {
    const validationState = error ? 'error' : warning ? 'warning' : 'success';
    return (
      <FormGroup controlId={input.name} validationState={touched ? validationState : null}>
        <Col componentClass={ControlLabel} sm={2}>{label}</Col>
        <Col sm={5}>
          <input {...input} id={input.name} placeholder={placeholder} type={type} className={'form-control'}/>
          {
            touched && error &&
            <HelpBlock>{error}</HelpBlock>
          }
        </Col>
      </FormGroup>
    )
  };

上記の関数の引数にある inputField コンポーネントが渡してくる入力値(value)や onChange,onBlur といった input コンポーネントに必要となるプロパティを持つオブジェクトになります。通常はこれをそのままinputコンポーネント等にpropsとして展開します。meta は フィールドに関連したエラー情報や初期値と同一かどうか、などの情報を持ってます。その他は通常のコンポーネントと同様に上位のコンポーネントから渡されてるprops値です。

次にこの関数を先程のFieldコンポーネントに指定するようにします。

src/MyForm.js
    <Form horizontal onSubmit={handleSubmit}>
      <Field
        name="name"
        component={renderField}
        type="text"
        label="お名前"
      />
    ...

これで、validation結果をスタイル指定と共に表示することができました。

スクリーンショット 2017-12-16 11.10.27.png

この validate(とその他のreduxFormで指定可能なオプション) ですが、reduxForm での宣言時に指定しなくても、対象となるコンポーネントのpropsとして渡すことが可能になってます。上位のコンポーネントの挙動によってチェック内容を動的に変化させるなどの際使います。

初期化したい

編集画面などで初期化した状態でフォーム画面表示したい場合には、reduxFormのパラメータとしてinitialValuesの名称で初期値指定をします。ここでは固定で初期値設定してますが、MyFormコンポーネントのpropsとして動的に指定することも可能なことを押させておきましょう。

src/MyForm.js
export default reduxForm({
  form: 'myForm',
  validate: myValidation,
  initialValues: { name: 'デフォルト表示' } //<--追加
})(MyForm);

画面リロードすると表示されました。

スクリーンショット 2017-12-16 11.41.32.png

この時何か編集をしてクリアボタン押すと、初期値に戻ります。ボタン押下時の動作は

src/MyForm.js
<Button type="button" disabled={pristine || submitting} onClick={reset}>クリア</Button>

で定義されてますが、ここのresetはredux-formが提供する関数で、storeに保存されている初期値に戻す動作をしてくれます。

特定項目の値で挙動を変えたい

ある項目の入力可否が他の項目に依存していたり、特定項目の入力値で挙動を変えるケースはよくありますが、その場合は描画時に特定の項目値を取得する必要があります。redux-formではformValueSelector APIが用意されてます。

最初にselectorの定義と、そこから取得したフィールド値をreduxのconnect関数使ってMyFormコンポーネントに渡すように定義します。

src/MyForm.js
MyForm = reduxForm({
  form: 'myForm',
  validate: myValidation,
  initialValues: { name: 'デフォルト表示' }
})(MyForm);

const selector = formValueSelector('myForm');

MyForm = connect(state => {
  const contactValue = selector(state, 'contact')
  return {
    contactValue,
  }
})(MyForm)

export default MyForm

次にフォームの実装に追加修正します。今回は連絡先の選択によってフィールドの表示制御をしてます。

src/MyForm.js
let MyForm = props => {
  const { handleSubmit, pristine, reset, submitting, contactValue } = props;
  return (
    <Form horizontal onSubmit={handleSubmit}>
      <Field
        name="name"
        component={renderField}
        type="text"
        label="お名前"
        placeholder="オープン太郎"
      />
      <FormGroup controlId={'contact'}>
        <Col componentClass={ControlLabel} sm={2}>連絡先</Col>
        <Col sm={5}>
          <label className="radio-inline">
            <Field
              name="contact"
              id="contact"
              component="input"
              type="radio"
              value="email"
            /> メール
          </label>
          <label className="radio-inline">
            <Field
              name="contact"
              id="contact"
              component="input"
              type="radio"
              value="phone"
            /> 電話
          </label>
        </Col>
      </FormGroup>
      {
        contactValue === 'email' &&
        <Field
          name="email"
          component={renderField}
          type="text"
          label="メールアドレス"
          placeholder="example@example.com"
        />
      }
      {
        contactValue === 'phone' &&
        <Field
          name="phone"
          component={renderField}
          type="text"
          label="電話番号"
          placeholder="09011112222"
        />
      }
      <FormGroup>
        <Col smOffset={2} sm={5}>
          <ButtonToolbar>
            <Button bsStyle={'primary'} type="submit" disabled={pristine || submitting}>登録</Button>
            <Button type="button" disabled={pristine || submitting} onClick={reset}>クリア</Button>
          </ButtonToolbar>
        </Col>
      </FormGroup>
    </Form>
  );
};

できました。

スクリーンショット 2017-12-19 14.54.53.png

階層化されたフィールド

複数の連絡先や家族情報の入力など、動的で且つ階層化された入力欄と、それに合わせて階層化された値の取得をしたい場合があります。今回は家族の名前を追加できるようにしてみたいと思います。

同一のフォームの配列はredux-formでは FieldArray コンポーネントを使って実現できます。MyFormコンポーネントの登録ボタン上あたりに下記の記述を追記します。

src/MyForm.js
<FieldArray name="families" component={renderFamilies} />

FieldArrayFieldと同様にname属性を持ちますが、これはcomponent属性で指定されたコンポーネントで記述されるFieldコンポーネントの配列を表す名称となります。

配列形式で描画したいコンポーネントの実装を追加します。

src/MyForm.js
const renderFamilyFields = (families, index, fields) => (
  <li key={index} className={'list-group-item'}>
    <Button type="button" style={{marginRight: '10px'}} onClick={() => fields.remove(index)}>削除</Button>
    <span style={{marginRight: '10px'}}>家族 {index + 1}</span>
    <Field
      name={`${families}.name`}
      type="text"
      component="input"
      label="家族氏名"
    />
  </li>
);

const renderFamilies = ({ fields }) => (
  <FormGroup>
    <Col componentClass={ControlLabel} sm={2}>家族</Col>
    <Col sm={8}>
      <ButtonToolbar>
        <Button type="button" onClick={() => fields.push({})}>追加</Button>
      </ButtonToolbar>
      <ul className="list-group">
        {fields.map(renderFamilyFields)}
      </ul>
    </Col>
  </FormGroup>
)

ここで渡される fields によって、コンポーネントの追加、削除や列挙をすることができます。

画面で値を入れて登録ボタン押下してみると、

スクリーンショット 2017-12-16 20.53.51.png

こんな感じで families の配下に複数のFieldからの入力値が配列形式で格納されてるのがわかります。

非同期でサーバーでのvalidationがしたい

通常はsubmitした値をサーバーサイドでチェック、問題があればエラーを返しますが、時々非同期でサーバーにお伺いを立てたい場合もあります。

ここでは電話番号を非同期チェックしてみたいと思います。まず、サーバーサイドvalidationをエミュレートする実装を用意します。

src/asyncValidate.js
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))

export default (async function asyncValidate(values) {
  await sleep(1000);
  if (!!values.phone && !values.phone.match(/^(0[5-9]0[0-9]{8}|0[1-9][1-9][0-9]{7})$/)) {
    throw { phone: '不正な電話番号です' }
  }
});

不正な値だった場合には、通常のvalidationと同じように、フィールド名とエラーメッセージの連想配列の形式でエラーを返します。

次に、これを使って reduxForm のパラメータで非同期チェックの定義を追加します。

src/MyForm.js
MyForm = reduxForm({
  form: 'myForm',
  validate: myValidation,
  initialValues: { name: 'デフォルト表示' },
  asyncValidate, //<-- 追加
  asyncBlurFields: ['phone'], //<-- 追加
})(MyForm);

ここでは phone のフィールドのblurのタイミングでサーバーチェックを行う、という指定になってます。

これで非同期チェックが可能になりました。

スクリーンショット 2017-12-16 21.34.49.png

複数画面に分割されたフォーム

モーダルや画面遷移などで表示された別フォームでの入力を、同じフォームの値として保持したい場合にどうするか、というケースになります。

先程実装した、家族の情報を入力するフォームを、別画面にしてみます。src/App.jsにページ番号をthis.state.pageに持つようにして、表示画面を制御します。

src/App.js
import React, { Component } from 'react';
import {
  Grid,
  Row,
  Col,
} from 'react-bootstrap';
import logo from './logo.svg';
import './App.css';
import showResults from "./showResults";
import MyForm from "./MyForm";
import MyFamilyForm from './MyFamilyForm';

class App extends Component {

  constructor(props) {
    super(props)
    this.nextPage = this.nextPage.bind(this)
    this.state = {
      page: 1
    }
  }

  nextPage() {
    this.setState({ page: this.state.page + 1 })
  }

  render() {
    return (
      <div>
        <header className="App App-header">
          <img src={logo} className="App-logo" alt="logo" />
          <h1 className="App-title">Welcome to React</h1>
        </header>
        <Grid style={{padding: 15}}>
          <Row>
            <Col sm={10}>
              <h3>フォーム画面</h3>
              {
                this.state.page === 1 &&
                <MyForm onSubmit={this.nextPage}/>
              }
              {
                this.state.page === 2 &&
                <MyFamilyForm onSubmit={showResults}/>
              }
            </Col>
          </Row>
        </Grid>
      </div>
    );
  }
}

export default App;

src/MyForm.jsの実装は家族情報の部分を除外したものにします。

src/MyForm.js
let MyForm = props => {
  const { handleSubmit, pristine, reset, submitting, contactValue } = props;
  return (
    <Form horizontal onSubmit={handleSubmit}>
      <Field
        name="name"
        component={renderField}
        type="text"
        label="お名前"
        placeholder="オープン太郎"
      />
      <FormGroup controlId={'contact'}>
        <Col componentClass={ControlLabel} sm={2}>連絡先</Col>
        <Col sm={5}>
          <label className="radio-inline">
            <Field
              name="contact"
              id="contact"
              component="input"
              type="radio"
              value="email"
            /> メール
          </label>
          <label className="radio-inline">
            <Field
              name="contact"
              id="contact"
              component="input"
              type="radio"
              value="phone"
            /> 電話
          </label>
        </Col>
      </FormGroup>
      {
        contactValue === 'email' &&
        <Field
          name="email"
          component={renderField}
          type="text"
          label="メールアドレス"
          placeholder="example@example.com"
        />
      }
      {
        contactValue === 'phone' &&
        <Field
          name="phone"
          component={renderField}
          type="text"
          label="電話番号"
          placeholder="09011112222"
        />
      }
      <FormGroup>
        <Col smOffset={2} sm={5}>
          <ButtonToolbar>
            <Button bsStyle={'primary'} type="submit" disabled={pristine}>次へ</Button>
          </ButtonToolbar>
        </Col>
      </FormGroup>
    </Form>
  );
};

更に、ここが注意点ですが、redux-form のデフォルト設定では、コンポーネントが unmount すると redux の store に保存されている入力値が消えてしまうのですが、そうならない設定を追記します。これで画面遷移しても遷移先画面で前画面での入力値を保持することができます。

src/MyForm.js
MyForm = reduxForm({
  form: 'myForm',
  destroyOnUnmount: false, //<-- 追加
  forceUnregisterOnUnmount: true, //<-- 追加
  validate: myValidation,
  initialValues: { name: 'デフォルト表示' },
  asyncValidate,
  asyncBlurFields: ['phone'],
})(MyForm);

次に家族情報を入力する画面を追加します。こちらも同様にコンポーネントの unmount 時に値が消えないよう設定します。

src/MyForm.js
import React from 'react';
import { Field, FieldArray, reduxForm } from 'redux-form';
import {
  Form,
  FormGroup,
  Col,
  ControlLabel,
  Button,
  ButtonToolbar,
} from 'react-bootstrap';
import asyncValidate from './asyncValidate';
import myValidation from './myValidation';

const renderFamilyFields = (families, index, fields) => (
  <li key={index} className={'list-group-item'}>
    <Button type="button" style={{marginRight: '10px'}} onClick={() => fields.remove(index)}>削除</Button>
    <span style={{marginRight: '10px'}}>家族 {index + 1}</span>
    <Field
      name={`${families}.name`}
      type="text"
      component="input"
      label="家族氏名"
    />
  </li>
);

const renderFamilies = ({ fields }) => (
  <FormGroup>
    <Col componentClass={ControlLabel} sm={2}>家族</Col>
    <Col sm={8}>
      <ButtonToolbar>
        <Button type="button" onClick={() => fields.push({})}>追加</Button>
      </ButtonToolbar>
      <ul className="list-group">
        {fields.map(renderFamilyFields)}
      </ul>
    </Col>
  </FormGroup>
)

let MyFamilyForm = props => {
  const { handleSubmit, pristine, reset, submitting, contactValue } = props;
  return (
    <Form horizontal onSubmit={handleSubmit}>
      <FieldArray name="families" component={renderFamilies} />
      <FormGroup>
        <Col smOffset={2} sm={5}>
          <ButtonToolbar>
            <Button bsStyle={'primary'} type="submit" disabled={pristine}>登録</Button>
          </ButtonToolbar>
        </Col>
      </FormGroup>
    </Form>
  );
};

MyFamilyForm = reduxForm({
  form: 'myForm',
  destroyOnUnmount: false,
  forceUnregisterOnUnmount: true,
  validate: myValidation,
  asyncValidate,
})(MyFamilyForm);

export default MyFamilyForm

また、validationは共通でまとめて、必要箇所でimportします。

src/myValidation.js
const myValidation = values => {
  const errors = {}
  if (!values.name) {
    errors.name = '必須項目です!'
  } else if (values.name.length > 10) {
    errors.name = '10文字以内で指定してください'
  }
  return errors
}

export default myValidation;

スクリーンショット 2017-12-19 15.00.38.png

画面遷移して登録ボタン押すと

スクリーンショット 2017-12-19 15.01.54.png

このように複数画面をまたいだ値を submit することができました。

これはモーダルダイアログを一時的に表示して入力をさせる場合などでも応用できます。

(補足) v5 → v6 の変更点

これから使用する方は v7 になりますので全く無関係ですが、 redux-form は v6 の時に仕様が一新しました。初期から使用している方向けに少し補足したいと思います。

公式には migrationガイド を参照いただきたいのですが、一番の変更点は、redux-formが管理するフィールド値が、v5時代はフォーム全体のプロパティとして扱われていたのですが、v6 になってコンポーネント化した点になります。v5風に今回実装のフォームを定義すると、

src/MyForm.js
MyForm = reduxForm({
  form: 'myForm',
    fields: [ 'name', 'contact', 'email', 'phone', 'families[].name' ],  //<-- fields
  destroyOnUnmount: false,
  validate: myValidation,
  initialValues: { name: 'デフォルト表示' },
  asyncValidate,
  asyncBlurFields: ['phone'],
})(MyForm);

こんな感じになりますが、ここで定義された fields 内の各名称のフィールドオブジェクトが生成されるようになります。

ここで問題になったのは、フォーム内の項目数が増えた際に、1つのフィールドが更新されただけで、全てのフィールドのvalidation等の処理が走り、再描画がされる点でした。ですので、フィールド数が一定以上になると極端に画面の再描画が重くなってしまいます。

そこで、個別フィールドごとに Field コンポーネントで管理をする形で分離をし、submit時などにフォーム全体としての値をマージする形に v6 から仕様が変わりました。直感的には v5 のほうが分かりやすい、書きやすいと思いますが、今回もこの記事書いてて思いましたが、慣れればそれほど不都合でもないかな、と思います。

おわりに

今回は react-redux ベースのフロント周りの話でしたが、弊社ではそれ以外で vue.js での実装プロジェクトなども並行して実施しています。フロント周りは動きが激しいですが、その辺を楽しんで開発できる方、ぜひ弊社に遊びにきてください。