Rails+Vue.jsによるフォームの作例のサンプルを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版の記事を参照してください。
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との相性の悪さは今のところなさそうです。
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を作っています。
<script>
var jsProps = <%= { entryId: @entry.try(:id) }.to_json.html_safe %>;
</script>
<div id="entry-form"></div>
編集ページのフォーム
記事の編集フォームだけかいつまんで紹介します。
データの持ち方
コンポーネントは関数コンポーネントにします。記事のデータを扱うために、Hooksの機能useReducerを使ってます。アラートメッセージ用にはuseStateを使ってます。
import React, { useEffect, useReducer, useState } from 'react';
(中略)
export default function (props) {
const [entry, updateEntry] = useReducer(entryReducer, initialEntry);
const [alert, setAlert] = useState('');
(中略)
}
useReducerに渡す記事データの初期値です。
const initialEntry = {
title: '',
body: '',
tags: [],
published_at: '',
draft: false
};
useReducerに渡す関数です。記事全体、タグのリスト、チェックボックス、それ以外(テキスト入力欄)で場合分けして新しい記事オブジェクトを返します。
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です。
このような書き方でよいのかはよくわからないところです。
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>
);
}
子コンポーネントとして使うタグ入力欄のリスト、送信ボタン、削除ボタンです。
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 || ''
のようなことをしています。
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 />
のように書かないとなりません。
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に送ります。成功したときは指定のパスにリダイレクトします。失敗したときはアラート表示部にメッセージを入れます。
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のフレームワークに関する理解が深まります。