ハンズオン - 最小限の設定で Rails 使いでも気軽に始められる React アプリの基盤構築

ある程度の規模のプロジェクトではいつか辛くなるのでjQueryやめましょう。
どう始めればいいかわかりにくいと思うので、ハンズオン用の資料を書きました。

# 成果物

下記にコード置いてあります。適宜参考にしてください。
https://github.com/togana/tutorial-rails-react

# 対象者

- Railsがチョットデキル人
- JavaScriptがチョットデキル人
- React興味あってRailsでSSRしたい人

# 目標

最小限の設定で、 Rails 使いでも気軽に始められる React アプリの基盤を構築できる

## 叶えられること

- Rails だけど SPA っぽく画面遷移
- SSR

## 叶えられないこと

- State 管理(厳密にはできるけど今回は対象にしない)

# 環境構築

brewでいれるなら下記でok。
こだわりがある方はご自由に入れてください。
普通にnodenvとかrbenvとか使ったほうがいいと思う

```
$ brew install node
$ brew install yarn
$ brew install ruby
$ gem install bundler
```

# ハンズオンスタート

## プロジェクト作成

```console
$ mkdir tutorial-rails-react
$ cd tutorial-rails-react
$ bundle init
$ sed -i '' 's/# gem "rails"/gem "rails"/g' Gemfile
$ bundle install --path vendor/bundle -j4

# giboコマンド使ってrailsの良いignoreを用意
# https://www.gitignore.io/api/rails こちらで生成されたファイルを登録でも可
$ gibo rails > .gitignore

# -Bを指定しているのはbundleのディレクトリを指定したいため
# -Tを指定しているのは今回はテストしない && RSpecなどを入れやすくするため
$ bundle exec rails new . -BT
...略...
    conflict  .gitignore
Overwrite /xxx/tutorial-rails-react/.gitignore? (enter "h" for help) [Ynaqdh] n
        skip  .gitignore
    conflict  Gemfile
Overwrite /xxx/tutorial-rails-react/Gemfile? (enter "h" for help) [Ynaqdh] Y
       force  Gemfile
...略...
$ bundle install --path vendor/bundle -j4
```

## パッケージの導入

### webpackerの導入

```console
$ echo "gem 'webpacker', '~> 3.0'" >> Gemfile
$ bundle install
$ bundle exec rails webpacker:install
```

### Reactの導入

```console
$ bundle exec rails webpacker:install:react
```

### foremanの導入

```console
$ echo "gem 'foreman'" >> Gemfile
$ bundle install
$ echo -e "rails: bundle exec rails s -p 3000\nwebpack: bin/webpack-dev-server" > Procfile
```

## スキャフォールドで機能実装

```console
$ bundle exec rails g scaffold board title:string message:string
```

## マイグレーション実行

```console
$ bundle exec rails db:migrate
```

## 動作確認

```console
$ bundle exec foreman start
```

http://localhost:3000/boards にアクセスできればok

## SSRするためにNodeの住人導入

### hypernovaの導入(Rails側)

```console
$ echo "gem 'hypernova'" >> Gemfile
$ bundle install
```

```diff:app/controllers/application_controller.rb
+ require 'hypernova'
+
class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
+  around_action :hypernova_render_support
end
```

```diff:config/environments/development.rb
+ require 'hypernova'
+ require 'hypernova/plugins/development_mode_plugin'
+ 
+ Hypernova.add_plugin!(DevelopmentModePlugin.new)
+ 
Rails.application.configure do
  # Settings specified here will take precedence over those in config/application.rb.
```

```diff:config/initializers/hypernova.rb
+ # Really basic configuration only consists of the host and the port
+ Hypernova.configure do |config|
+   config.host = "localhost"
+   config.port = 3001
+ end
```

### hypernovaの導入(JavaScript側)

```console
$ yarn add hypernova-react hypernova babel-cli
$ yarn
```

```diff:.babelrc
 {
   "presets": [
-    [
-      "env",
-      {
-        "modules": false,
-        "targets": {
-          "browsers": "> 1%",
-          "uglify": true
-        },
-        "useBuiltIns": true
+    ["env", {
+      "targets": {
+        "node": "current"
       }
-    ],
+    }], 
     "react"
   ],
   "plugins": [
```

```console
$ mkdir node
```

```diff:node/hypernova.js
+ import hypernova from 'hypernova/server';
+ import * as exposed from '../app/javascript/exposeComponents';
+ 
+ hypernova({
+   devMode: process.env.NODE_ENV !== 'production',
+   getComponent(name) {
+     return exposed[name]
+   },
+   port: 3001
+ });
```

## Reactコンポーネントの実装

### コンポーネントの作成

```diff:app/javascript/components/Table.js
+ import React, { Component } from 'react';
+ 
+ export default class Table extends Component {
+   render() {
+     return (
+       <div>
+         <table>
+           <thead>
+             <tr>
+               <th>Title</th>
+               <th>Message</th>
+               <th colSpan={3}></th>
+             </tr>
+           </thead>
+           <tbody>
+             {this.props.boards.map((board) => (
+               <tr key={board.id}>
+                 <td>{board.title}</td>
+                 <td>{board.message}</td>
+                 <td><a href={`/boards/${board.id}`}>Show</a></td>
+                 <td><a href={`/boards/${board.id}/edit`}>Edit</a></td>
+                 <td><a data-confirm="Are you sure?" rel="nofollow" data-method="delete" href={`/boards/${board.id}`}>Destroy</a></td>
+               </tr>
+             ))}
+           </tbody>
+         </table>
+       </div>
+     );
+   }
+ }
```

### コンポーネントをviewから呼び出す

```diff:app/javascript/exposeComponents.js
+ import { renderReact } from 'hypernova-react';
+ import _Table from './components/Table';
+ 
+ export const Table = renderReact('Table', _Table);
```

```diff:app/javascript/packs/application.js
-/* eslint no-console:0 */
-// This file is automatically compiled by Webpack, along with any other files
-// present in this directory. You're encouraged to place your actual application logic in
-// a relevant structure within app/javascript and only use these pack files to reference
-// that code so it'll be compiled.
-//
-// To reference this file, add <%= javascript_pack_tag 'application' %> to the appropriate
-// layout file, like app/views/layouts/application.html.erb
-
-console.log('Hello World from Webpacker')
+import '../exposeComponents';
```

```diff:app/views/layouts/application.html.erb

  <body>
    <%= yield %>
+     <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
  </body>
</html>
```

```diff:app/views/boards/index.html.erb
 
 <h1>Boards</h1>
 
-<table>
-  <thead>
-    <tr>
-      <th>Title</th>
-      <th>Message</th>
-      <th colspan="3"></th>
-    </tr>
-  </thead>
-
-  <tbody>
-    <% @boards.each do |board| %>
-      <tr>
-        <td><%= board.title %></td>
-        <td><%= board.message %></td>
-        <td><%= link_to 'Show', board %></td>
-        <td><%= link_to 'Edit', edit_board_path(board) %></td>
-        <td><%= link_to 'Destroy', board, method: :delete, data: { confirm: 'Are you sure?' } %></td>
-      </tr>
-    <% end %>
-  </tbody>
-</table>
+<%= render_react_component('Table', { boards: @boards}) %>
 
 <br>
```

## foremanにhypernova追加

```diff:Procfile
rails: bundle exec rails s -p 3000
webpack: bin/webpack-dev-server
+ hypernova: yarn babel-node node/hypernova.js
```

## 動作確認

```console
$ bundle exec foreman start
```

http://localhost:3000/boards にアクセスして下記のような画面が確認できればOK

<img width="331" alt="表示されてほしい画面" src="https://qiita-image-store.s3.amazonaws.com/0/82745/ad435ce3-bdca-599a-6136-3175d3514a5d.png">

New BoardをClickしてデータの登録ができると下記のような画面になります。

<img width="347" alt="登録後の表示されてほしい画面" src="https://qiita-image-store.s3.amazonaws.com/0/82745/373317ce-eef5-adea-cd66-47f92a7f50f5.png">

Table周りをコンポーネント化することに成功しました。

## 発展(状態を持たせる)

### 検索できるTableコンポーネントにする

```diff:app/javascript/components/Table.js
import React, { Component } from 'react';

export default class Table extends Component {
+   constructor(props) {
+     super(props);
+     this.state = {
+       filterText: '',
+     };
+   }
+ 
+   setFilter(filterText){
+     this.setState({ filterText: filterText.target.value.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&') });
+   }
+ 
  render() {
    return (
      <div>
+         <div>
+           <lbabel>検索</lbabel>
+           <input type='text' onChange={(text) => this.setFilter(text)} />
+         </div>
        <table>
          <thead>
            <tr>
```

```diff
             </tr>
           </thead>
           <tbody>
-            {this.props.boards.map((board) => (
+            {this.props.boards.filter(board => board.title.match(new RegExp(this.state.filterText))).map((board) => (
               <tr key={board.id}>
                 <td>{board.title}</td>
                 <td>{board.message}</td>

```

`bundle exec foreman start` を再度実行して結果を確認しましょう。

![demo.gif](https://qiita-image-store.s3.amazonaws.com/0/82745/6e9c7427-e17d-41c3-991a-a71707da2b29.gif)

### pm2の導入

コンポーネント書き換えるたびにサーバ再起動してもらいたいので、watchできて本番でもよく使われるpm2導入しました。

```console
$ yarn add -D pm2
```


```diff:pm2.json
+ {
+   "apps" : [{
+     "name": "hypernova",
+     "script": "./node/hypernova.js",
+     "watch": "app/javascript",
+     "interpreter": "./node_modules/.bin/babel-node"
+   }]
+ }
```

```diff:Procfile
 rails: bundle exec rails s -p 3000
 webpack: bin/webpack-dev-server
-hypernova: yarn babel-node node/hypernova.js
+hypernova: yarn pm2 startOrRestart pm2.json -- --no-daemon
```

pm2のモニタリング方法

```console
$ yarn pm2 monit
```

<kbd>Ctrl-C</kbd> で foreman を止めてもpm2は別のプロセスになるので消えません。
下記を実行する必要があります。

```console
$ yarn pm2 delete pm2.json
```

# まとめ

かなり少ない設定で、最低限動く Rails + React アプリの基盤になっていると思います。
(turbolinksを使うことでSPAっぽくもなってるので表示速度もそこそこ速い！)

使い方は、作成したコンポーネントを `app/javascript/exposeComponents.js` に追加して render側で `<%= render_react_component('コンポーネント名', Props) %>` 呼び出して上げるだけです。
これで dom の更新の多いところ(domが状態を持ってしまって頭がいたい)だけ React 化する運用ができると思います。

また、state 管理にReduxやMobX等を利用したほうが良いですが、詰め込みすぎると混乱すると思うので控えました。