Rails+Reactによるフォームの作例

[Rails+Vue.jsによるフォームの作例](https://qiita.com/kazutosato/items/38caffdbd21508a5c126)のサンプルをReactに移植してみました。

サンプルプログラムはこちら。簡単なブログアプリケーションです。masterブランチでは、Hooksを使っています。
https://github.com/kazubon/blog-rails6-react
別ブランチでは `class extends React.Component` を使ったものも作りました。
https://github.com/kazubon/blog-rails6-react/tree/extend

Rails側の作り方や全体的なポイントはVue版と同じですので、[Vue版の記事](https://qiita.com/kazutosato/items/38caffdbd21508a5c126)を参照してください。

React歴2週間ですので、ヘンなところがあればご指摘ください。

## 環境

- Rails 6.0、Webpacker 4.2、React 16.12。
- 非SPA、Turbolinksあり。
- jQueryとBootstrapあり。

## application.js

packs下のapplication.jsはこんな感じです。SPAではないので、ページ遷移するたびにReactコンポーネントを初期化します。HTML要素をid属性で探して、対応するReactコンポーネントを`ReactDOM.render`します。

Vueと違い、Turbolinksとの相性の悪さは今のところなさそうです。

```javascript:app/javascript/packs/application.js
import "core-js/stable";
import "regenerator-runtime/runtime";

require("@rails/ujs").start();
require("turbolinks").start();

import React from 'react';
import ReactDOM from 'react-dom';

import EntryIndex from '../entries/index';
import EntryForm from '../entries/form';
import EntryStar from '../entries/star';
import Flash from '../flash';
import '../axios_config';

document.addEventListener('turbolinks:load', () => {
  Flash.show();

  let apps = [
    { elem: '#entry-index', object: EntryIndex },
    { elem: '#entry-form', object: EntryForm },
    { elem: '#entry-star', object: EntryStar }
  ];

  let props = window.jsProps || {};
  apps.forEach((app) => {
    if($(app.elem).length) {
      ReactDOM.render(React.createElement(app.object, props), $(app.elem)[0]);
    }
  });
});
```

Reactを埋め込むHTMLのテンプレートでは、Reactコンポーネントのpropsに渡すために、グローバル変数jsPropsを作っています。

```erb:app/views/entries/edit.html.erb
<script>
var jsProps = <%= { entryId: @entry.try(:id) }.to_json.html_safe %>;
</script>
<div id="entry-form"></div>
```

## 編集ページのフォーム

記事の編集フォームだけかいつまんで紹介します。

### データの持ち方

コンポーネントは関数コンポーネントにします。記事のデータを扱うために、Hooksの機能useReducerを使ってます。アラートメッセージ用にはuseStateを使ってます。

```javascript:app/javascript/entries/form.js
import React, { useEffect, useReducer, useState } from 'react';
（中略）
export default function (props) {
  const [entry, updateEntry] = useReducer(entryReducer, initialEntry);
  const [alert, setAlert] = useState('');
（中略）
}
```

useReducerに渡す記事データの初期値です。

```javascript:app/javascript/entries/form.js
const initialEntry = {
  title: '',
  body: '',
  tags: [],
  published_at: '',
  draft: false
};
```
useReducerに渡す関数です。記事全体、タグのリスト、チェックボックス、それ以外（テキスト入力欄）で場合分けして新しい記事オブジェクトを返します。

```javascript:app/javascript/entries/form.js
function entryReducer(entry, action) {
  switch(action.name) {
    case 'entry':
      return action.entry;
    case 'tag':
      let tags = entry.tags.map(t => ({ name: t.name }));
      tags[action.index] = { name: action.value };
      return { ...entry, tags };
    case 'draft':
      return { ...entry, draft: action.checked };
    default:
      return { ...entry, [action.name]: action.value };
  }
}
```

### テンプレート

関数コンポーネントが返すテンプレートです。この関数は、データに変更があるたびに呼び出されるので、`return (<...>)` 以外の部分はなるべく小さめにしてみました。

フォームを送信するhandleSubmitやデータを読み込むgetEntryでは、関数の引数に関数を渡しています。useReducerが返したupdateEntryや、useStateが返したsetAlertです。

このような書き方でよいのかはよくわからないところです。

```jsx:app/javascript/entries/form.js
export default function (props) {
  const [entry, updateEntry] = useReducer(entryReducer, initialEntry);
  const [alert, setAlert] = useState('');

  useEffect(() => {
    getEntry(props, updateEntry);
  }, []);

  function handleChange(e) {
    updateEntry({name: e.target.name, value: e.target.value,
      checked: e.target.checked });
  }

  return (
    <form onSubmit={e => handleSubmit(e, props.entryId, entry, setAlert)}>
      {alert && <div className="alert alert-danger">{alert}</div>}
      <div className="form-group">
        <label htmlFor="entry-title">タイトル</label>
        <input type="text" id="entry-title" name="title" className="form-control"
          required="" maxLength="255" pattern=".*[^\s]+.*"
          value={entry.title} onChange={handleChange} />
      </div>
      <div className="form-group">
        <label htmlFor="entry-body">本文</label>
        <textarea id="entry-body" name="body" cols="80" rows="15"
          className="form-control" required="" maxLength="40000"
          value={entry.body} onChange={handleChange} />
      </div>
      <div className="form-group">
        <label htmlFor="entry-tag0">タグ</label>
        <div>
          <TagList tags={entry.tags}
            onChange={(idx, value) => updateEntry({ name: 'tag', index: idx, value: value })} />
        </div>
      </div>
      <div className="form-group">
        <label htmlFor="entry-published_at">日時</label>
        <input type="text" id="entry-published_at" name="published_at"
          className="form-control"
          pattern="\d{4}(-|\/)\d{2}(-|\/)\d{2} +\d{2}:\d{2}"
          value={entry.published_at} onChange={handleChange} />
      </div>
      <div className="form-group mb-4">
        <input type="checkbox" id="entry-draft" name="draft" value="1"
          checked={entry.draft} onChange={handleChange} />
        <label htmlFor="entry-draft">下書き</label>
      </div>
      <div className="row">
        <SubmitButton entryId={props.entryId} />
        {props.entryId &&
          <DeleteButton
            onClick={() => handleDelete(props.entryId, setAlert)} />}
      </div>
    </form>
  );
}
```

子コンポーネントとして使うタグ入力欄のリスト、送信ボタン、削除ボタンです。

```jsx:app/javascript/entries/form.js
function TagList(props) {
  return props.tags.map((tag, idx) => (
    <input key={idx}
      value={tag.name}
      onChange={e => props.onChange(idx, e.target.value)}
      className="form-control width-auto d-inline-block mr-2"
      style={{width: '17%'}} maxLength="255" />
  ));
}

function SubmitButton(props) {
  let text = props.entryId ? '更新' : '作成';
  return (
    <div className="col">
      <button type="submit" className="btn btn-outline-primary">{text}</button>
    </div>
  );
}

function DeleteButton(props) {
  return (
    <div className="col text-right">
      <button type="button" className="btn btn-outline-danger"
        onClick={props.onClick}>削除</button>
    </div>
  );
}
```

### データの読み込み

useEffectを使い、コンポーネントがマウントされたときにAjaxでデータを読み込みます。useReducerが返すupdateEntryを呼び出す→entryReducerが呼ばれる→useReducerが返すentryが更新される、となります。

Reactでは、入力欄の値がnullだとエラーになるので、`entry.title || ''`のようなことをしています。

```javascript:app/javascript/entries/form.js
function getEntry(props, updateEntry) {
  let path = props.entryId ? `/entries/${props.entryId}/edit` : '/entries/new';

  Axios.get(path + '.json').then((res) => {
    let entry = res.data.entry;
    updateEntry({
      name: 'entry',
      entry: {
        title: entry.title || '',
        body: entry.body || '',
        tags: initTags(entry.tags),
        published_at: entry.published_at,
        draft: entry.draft
      }
    });
  });
}
（中略）
export default function (props) {
（中略）
  useEffect(() => {
    getEntry(props, updateEntry);
  }, []);
（中略）
}
```

### 入力欄の値のバインディング

Vueのv-modelのようなものはReactにありません。入力欄の値が変更されたときに記事データを更新するには、inputにvalueとonChangeをセットで加えます。onChangeから呼び出すhandleChangeでは、useReducerが返す関数updateEntryを呼び出します。

なお、Reactのテンプレートでは、閉じタグのないHTML要素は `<input />` のように書かないとなりません。

```jsx:app/javascript/entries/form.js
export default function (props) {
（中略）
  function handleChange(e) {
    updateEntry({name: e.target.name, value: e.target.value,
      checked: e.target.checked });
  }

  return (
（中略）
        <input type="text" id="entry-title" name="title" className="form-control"
          required="" maxLength="255" pattern=".*[^\s]+.*"
          value={entry.title} onChange={handleChange} />
（中略）
  );
}
```

### フォームの送信

フォームを送信する部分です。useReducerによって更新されたentryをそのままRailsに送ります。成功したときは指定のパスにリダイレクトします。失敗したときはアラート表示部にメッセージを入れます。

```jsx:app/javascript/entries/form.js
function handleSubmit(e, entryId, entry, setAlert) {
  e.preventDefault();
  if(!validate(entry, setAlert)) { return }

  let path = entryId ? `/entries/${entryId}` : '/entries';
  Axios({
    method: entryId ? 'patch' : 'post',
    url: path + '.json',
    data: { entry: entry }
  }).then((res) => {
    Flash.set({ notice: res.data.notice });
    Turbolinks.visit(res.data.location);
  }).catch((error) => {
    if(error.response.status == 422) {
      setAlert(error.response.data.alert);
    }
    else {
      setAlert(`${error.response.status} ${error.response.statusText}`);
    }
    window.scrollTo(0, 0);
  });
}
（中略）
export default function (props) {
（中略）
  return (
    <form onSubmit={e => handleSubmit(e, props.entryId, entry, setAlert)}>
（中略）
    </form>
  );
}
```

## Vue.jsと比べて

- Reactには、Vueと違ってHTMLファイルの中の要素を取り出してテンプレートとして使う機能はありません。必ずJavaScript内のテンプレートを使います。
- ReactはVueよりソースコードの文字数が多くなります。
- Reactは素のJavaScriptに近いので、だいたい予想通りに動作します。Vueのように想定外の動きをすることはなさそうです。
- で、お仕事のプロジェクトでReactとVueどっちを採用するかというと、Vueとなります。Vueは本当は難しいのですが、少なくともとっつきやすい見た目をしています。スキルがバラバラなメンツを集めたプロジェクトでReactを使うと、どこから手を付けてよいやら途方に暮れる人が出そう。JavaScript大好きっ子が集まるプロジェクト（現実にあるのか？）なら、Reactはありでしょう。
- 個人的なプロジェクトでは、好きなほう使えばいいんじゃないでしょうか。というか、両方やればJavaScriptのフレームワークに関する理解が深まります。