Rails
bootstrap
reactjs
Rails5
webpacker

Rails 5.1 API mode + webpacker + react + reactstrap で ToDO アプリを書く

モチベーション

というわけで、1時間くらいで試した記録を残しておくことにしました。

追加記事

対象読者

  • Rails は触った事があるが、Rails 5.1 の webpacker は触ったことがない人
  • フロントエンド技術を(軽く)触ってみたい人
    • コードサンプルの意味を知るにはある程度 React やっておいた方がスムーズかもしれません
    • 意味分からなくても後で調べればきっと分かるよ!

作業場所(Github)

実行環境

  • Mac OS X (もしくは Linux でも)
  • Ruby 2.5.1
    • rbenv や rvm などでセットアップが終わっている前提とします
    • bundler はインストール済み (gem install bundler)

デモ

  • こんな感じのものを作ります
    rails5.1_and_react_reactstrap.gif

  • 機能

    • タスクの作成
    • タスクの削除
  • UI は Bootstrap 4 + reactstrap を利用

  • デモ上だと分かりづらいですが、 React で SPA っぽく動いています

  • POST メソッドを Rails 側に送信してタスク作成

  • 削除時には DELETE メソッドでタスクの削除を実現しています

プロジェクトの準備

Rails をインストール

プロジェクトディレクトリの作成 & bundle init でGemfile の作成

$ mkdir rails_react_tutorial
$ cd rails_react_tutorial
$ bundle init

Gemfile に gem "rails", "~> 5.1.0" を追記

+gem 'rails', '~> 5.1.0'

Rails を vendor/bundle ディレクトリ以下にインストール

$ bundle install --path=vendor/bundle

プロジェクトの初期化

インストールが完了したら、bundle exec rails new コマンドでプロジェクトの初期化をします

$ bundle exec rails new . --webpack=react --api 

各オプションの意図

  • --api
    • Rails サーバ側自体は API モードで利用する
  • --webpack=react
    • React を利用するので webpack=react を指定
    • angular や vue.js なども指定できます

コマンド実行時に以下のような出力がされますが、上書きしても問題ないので Y で進めます

[XXXX@XXXXX:~/work/rails_react_tutorial]$ bundle exec rails new . --webpack=react --api
       exist  
      create  README.md
      create  Rakefile
      create  config.ru
      create  .gitignore
    conflict  Gemfile
Overwrite /Users/XXXXX/work/rails_react_tutorial/Gemfile? (enter "h" for help) [Ynaqdh] Y

インストールまで完了すると rails コマンドなどが使えるようになっていると思います
興味がある方は ./bin/rake -T などをしてタスク一覧を見てみると面白いと思います

React アプリを Rails 側で配信する環境を整える

  • Rails 側は API mode で作成しているため、デフォルトでは HTML を配信しません
  • React アプリを配信するためのページが必要なので、セットアップを行っていきます

Controllerを作成

$ ./bin/rails g controller Home index

config/routes.rbhome#index が root になるように設定

config/routes.rb
 Rails.application.routes.draw do
+  root 'home#index'
   get 'home/index'

HomeController を以下のように変更する

app/controllers/home_controller.rb
-class HomeController < ApplicationController
+class HomeController < ActionController::Base
+  include ActionController::RequestForgeryProtection
+  include ActionController::ImplicitRender
+  include ActionView::Layouts
+
+  layout "application"
+
   def index
   end
 end
  • ApplicationController を継承すると JSON を返してしまうので ActionController::Base を継承させます
  • 通常の ActionView を HomeController 内のみで動かすために include ActionView::Layouts などを追記しています

app/views/layouts/application.html.erb を作成

app/views/layouts/application.html.erb
<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html;charset=UTF-8" />
    <title>SampleView</title>
    <%= csrf_meta_tags %>
  </head>

  <body>
    <%= yield %>
  </body>
</html>

app/views/home/index.html.erb を以下の内容で作成します

# まずはディレクトリを作っておく
$ mkdir app/views/home
app/views/home/index.html.erb
<!-- この内容で app/views/home/index.html.erb を作成 -->
<%= javascript_pack_tag 'hello_react' %>
  • hello_react というファイル名が出てきましたが、これは app/javascript/packs 以下にあります
  • React でコンポーネントを作成する場合には以後 app/javascript/packs に作成していきます

無事セットアップできたか確認する

Rails 側を起動します

$ ./bin/rails s

webpack-dev-server の起動が必要なため、別ターミナルなどで起動します

$ ./bin/webpack-dev-server

http://localhost:3000 にブラウザでアクセスして Hello React! と表示されれば、成功です

アプリを作る

  • やっと本筋に来ました
  • まずは API 側で CRUD ができるエンドポイントを作って、その後にフロントエンド(React)側を作り込んでいきます

Rails API(バックエンド)側の実装

モデルを作成

# Task モデルを作成
$ ./bin/rails g model task title:string description:string

# migrate して Task を DB に反映する
$ ./bin/rails db:migrate

コントローラを作成

$ ./bin/rails g controller Api::V1::Tasks
Running via Spring preloader in process 15338
      create  app/controllers/api/v1/tasks_controller.rb

app/controllers/api/v1/tasks_controller.rb に CRUD 用のアクションを一気に定義します

app/controllers/api/v1/tasks_controller.rb
class Api::V1::TasksController < ApplicationController
  before_action :set_task, only: [
    :show, :update, :destroy
  ]

  def index
    render json: Task.order(created_at: :desc).all
  end

  def create
    @task = Task.create!(task_params)
    render json: @task
  end

  def show
    render json: @task
  end

  def update
    @task.update(task_params)
    render json: @task
  end

  def destroy
    @task.destroy
    head :no_content
  end

  private

  def task_params
    params.permit(:title, :description)
  end

  def set_task
    @task = Task.find(params[:id])
  end
end

TasksController にエンドポイントとなるアクションを一通り作ったので、 config/routes.rbresources :tasks を追記します

config/routes.rb
  namespace :api, {format: 'json'} do
    namespace :v1 do
      resources :tasks
    end
  end

API からデータを取得できるように rails console でデータを登録しておきます

$ ./bin/rails c
Running via Spring preloader in process 17913
Loading development environment (Rails 5.1.6)
irb(main):001:0> Task.create(title: "foo", description: "bar")

curl http://localhost/api/v1/tasks をして作成したデータが返ってくれば OK です
また、 Task エントリの作成は以下のコマンドで確認できます

curl -H 'Content-Type:application/json' \
 -d '{"title":"title1","description":"desc1"}' \
 http://localhost:3000/api/v1/tasks

React アプリ(フロントエンド側)の実装

  • 見た目は無骨になりますが、Bootstrap の適用前に骨組みを作っていきます
  • まずはディレクトリ構成を決めます
    • ここでは最初に完成形の構造を晒してしまいます
  • app/javascript 以下に components ディレクトリを作成し、React のコンポーネントを格納するようにし、 application.js では app.jsx を import するようにします
├── components
│   ├── app.jsx
│   ├── header.jsx
│   ├── task-form.jsx
│   ├── task-row.jsx
│   └── task-table.jsx
└── packs
    └── application.js

まずは、 app/views/home/index.html.erb を以下のように修正します

app/views/home/index.html.erb
<div id='example-app'></div>
<%= javascript_pack_tag 'application' %>

上記の <div id=... の箇所が React アプリケーションがレンダリングされる場所です。
補足ですが、<%= javascript_pack_tag ...の箇所は、前述では hello_react を試しましたが、React の最初の起点は app/javascript/packs/application.js としたいので、application にしています。

各コンポーネントのコードと解説

  • ここではコードの例を示しつつ、インラインでコメントをしたほうが分かりやすいかな、と思ったので一気にコードを貼り付けています
app/javascript/packs/application.js
console.log('Hello World from Webpacker')

import React from 'react';
import ReactDOM from 'react-dom';
import App from '../components/app';

document.addEventListener('DOMContentLoaded', () => {
  ReactDOM.render(
    <App />, // 独自に記述した App クラス (../components/app.jsx)を指定
    document.getElementById('example-app')  // example-app をマウントポイントにする
  )
})
app/javascript/components/app.jsx
import React from 'react';
import Header from './header';
import TaskForm from './task-form';
import TaskTable from './task-table';

class App extends React.Component {
  constructor(props) {
    super(props);

    // タスク一覧を格納する配列を state として初期化
    this.state = {
      tasks: [],
    }

    // タスクを取得するメソッドを this に bind
    this.getTasks = this.getTasks.bind(this);
  }

  componentDidMount() {
    // コンポーネントマウント時にタスク一覧を取得する
    this.getTasks()
  }

  getTasks() {
    // Rails 側の /api/v1/tasks に GET リクエストを送ってタスク一覧を取得
    let request = new Request('/api/v1/tasks', {
      method: 'GET',
      headers: new Headers({
        'Content-Type': 'application/json'
      })
    });

    fetch(request).then(function (response) {
      return response.json();
    }).then(function (tasks) {
      // 取得が完了したら state にセットする
      this.setState({
        tasks: tasks
      });
    }.bind(this)).catch(function (error) {
      console.error(error);
    });
  }

  render() {
    const { tasks } = this.state;

    return (
      <div>
        <Header title='Rails 5.1 + webpacker + React + Reactstrap Example' />
        <div>
          {/*
            * TaskForm コンポーネント起因でタスクを作成した際に
            * タスク一覧を再取得するために
            * getTasks メソッドを props として渡す
            */}
          <TaskForm getTasks={this.getTasks} />
          {/*
            * TaskRow (TaskTable の中) コンポーネント起因でタスクを削除した際に
            * タスク一覧を再取得するために
            * getTasks メソッドを props として渡す
            * tasks はタスク一覧
            */}
          <TaskTable tasks={tasks} getTasks={this.getTasks} />
        </div>
      </div>
    )
  }
}

export default App;
app/javascript/components/header.jsx
import React from 'react';

class Header extends React.Component {
  constructor(props) {
    super(props);
  }

  render() {
    // <Header title=' ... で渡された値を表示
    return (
      <div>
        <h1>{this.props.title}</h1>
      </div>
    )
  }
}

export default Header;
app/javascript/components/task-form.jsx
import React from 'react';

class TaskForm extends React.Component {
  constructor(props) {
    super(props)

    // 入力フォーム(input) 用の state をセットしておく
    this.state = {
      title: '',
      description: ''
    }

    // タスクを作成するメソッドを this に bind する
    this.createTask = this.createTask.bind(this);
  }

  createTask(event) {
    // Rails 側の /api/v1/tasks を POST メソッドで叩き、タスクを作成する
    let request = new Request('/api/v1/tasks', {
      method: 'POST',
      headers: new Headers({
        'Content-Type': 'application/json'
      }),
      body: JSON.stringify({
        title: this.state.title,
        description: this.state.description
      })
    });

    fetch(request).then(function (response) {
      return response.json();
    }).then((task) => {
      // タスク作成が成功したら、タスク一覧を再取得する
      this.props.getTasks();
    }).catch(function (error) {
      console.error(error);
    }).finally(() => {
      this.setState({
        title: '',
        description: ''
      })
    });

    // preventDefault でブラウザ起因の onSubmit イベントを打ち消す
    // この記述が無いとページが遷移してしまう
    event.preventDefault();
  }

  render() {
    let { title, description } = this.state;

    return (
      <form onSubmit={this.createTask}>
        <label>Title</label>
        <input
          type="text" value={title}
          placeholder="Title"
          onChange={(e) => {
            this.setState({
              title: e.target.value
            })
          }}
        />
        <label className="mr-sm-2">Description</label>
        <input
          type="text" value={description}
          placeholder="Description"
          onChange={(e) => {
            this.setState({
              description: e.target.value
            })
          }}
        />
        <input type="submit" value="Create Task" />
      </form>
    )
  }
}

export default TaskForm;
app/javascript/components/task-table.jsx
import React from 'react';
import TaskRow from './task-row'

class TaskTable extends React.Component {
  constructor(props) {
    super(props)
  }

  render() {
    const { tasks, getTasks } = this.props;

    // 渡された tasks を map で回し、TaskRow コンポーネントとしてまとめてレンダリング
    return (
      <table>
        <thead>
          <tr>
            <th>Title</th>
            <th>Description</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          {tasks.map(function (task, index) {
            return (
              <TaskRow
                key={index}
                id={task.id}
                title={task.title}
                description={task.description}
                getTasks={getTasks}
              />);
          }.bind(this))}
        </tbody>
      </table>
    );
  }
}

export default TaskTable;
app/javascript/components/task-row.jsx
import React from 'react';

class TaskRow extends React.Component {
  constructor(props) {
    super(props);

    this.deleteTask = this.deleteTask.bind(this);
  }

  deleteTask(id) {
    // Rails 側の /api/v1/tasks/{taskID} を DELETE メソッドで叩き、Task の削除を実行する
    let request = new Request(`/api/v1/tasks/${this.props.id}`, {
      method: 'DELETE',
      headers: new Headers({
        'Content-Type': 'application/json'
      }),
    });

    fetch(request).then(function (response) {
      return response;
    }).then(() => {
      // DELETE 完了後に再度タスク一覧を取得
      this.props.getTasks();
    }).catch(function (error) {
      console.error(error);
    });
  }

  render() {
    return (
      <tr>
        <td>{this.props.title}</td>
        <td>{this.props.description}</td>
        <td>
          <a href="#" onClick={() => this.deleteTask(this.props.id)}>Delete</a>
        </td>
      </tr>
    )
  }
}

export default TaskRow;

reactstrap + bootstrap 4 導入

yarnreactstrapbootstrap をインストールします

$ bundle exec yarn add reactstrap bootstrap@4.0.0

bootstrap を import するために、 app/javascript/packs/ 以下に bootstrap.scss を作成します

app/javascript/packs/bootstrap.scss
@import '~bootstrap/dist/css/bootstrap';

筆者はサクッと導入できると思っていましたが、ここで一工夫必要になります。
bootstrap 4 系は(まだ?) jQuery やその他の JS に依存しているため、 config/webpack/environment.js を以下のようにする必要があります

config/webpack/environment.js
const { environment } = require('@rails/webpacker')
const webpack = require('webpack')

environment.plugins.append('Provide', new webpack.ProvidePlugin({
    $: 'jquery',
    jQuery: 'jquery',
    Popper: ['popper.js', 'default']
}))

module.exports = environment

最後に app/views/layouts/application.html.erb に以下のタグを追記します

app/views/layouts/application.html.erb
+    <%= stylesheet_pack_tag 'bootstrap' %>

これで Rails 側から bootstrap が使えるようになりました。
上手く行っていれば、 React 側のタグに className などで bootstrap 4 のクラスを書くと適用されると思います。

適用例として https://github.com/kaishuu0123/rails5.1-react-reactstrap-example にコードを配置しましたので、参考になれば幸いです

所感

  • 意外と手軽にフロントエンド開発をすることができて驚いています
    • 一人プロジェクトで使うには十分実用的
    • コマンドが多いのでメモを残すのは結構辛かった(´-﹏-`;)
      • けど、実際にやってみると意外とサックリできると思います
  • 考えられる欠点
    • フロントエンドの技術が Rails プロジェクト内に混ざるので babel や webpack etc ... を理解していないと使うのは難しそう
    • チーム開発になるとフロントエンドとバックエンドは分けて考えた方がメリットある ... のかなぁ?
    • Turbolinks と連携するとなると辛そう

他にも色々試してみたいこと

  • Redux や React Router 導入
  • Sorcery gem を使って jwt 認証
  • React 以外も試してみたい (Angular, Vue.js)
  • 本番デプロイ方法の検討 (asset:precompile との連携など)
  • 今回は Rails 側に HTML の配布まで実装したが webpack-dev-server がせっかくあるのでもっと独立して フロントエンド/バックエンドで開発が分けられるか調べてみる