6
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-01-26

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との相性の悪さは今のところなさそうです。

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を作っています。

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を使ってます。

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に渡す記事データの初期値です。

app/javascript/entries/form.js
const initialEntry = {
  title: '',
  body: '',
  tags: [],
  published_at: '',
  draft: false
};

useReducerに渡す関数です。記事全体、タグのリスト、チェックボックス、それ以外(テキスト入力欄)で場合分けして新しい記事オブジェクトを返します。

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です。

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

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>
  );
}

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

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 || ''のようなことをしています。

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 /> のように書かないとなりません。

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に送ります。成功したときは指定のパスにリダイレクトします。失敗したときはアラート表示部にメッセージを入れます。

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のフレームワークに関する理解が深まります。
6
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?