Rails アプリに React.js を導入する react-rails を試してみる

react-rails という、React.js を Rails 上で使いやすくしてくれている Gem があったので入れてみる。

インストール

任意の Rails アプリの Gemfile に、以下を書く。

# React Rails
gem 'react-rails', '~> 1.0'

そしたら bundle install を実行してインストールする。

$ bundle install(省略)…
Installing react-rails 1.10.0
…(省略)

次に React.js を Rails アプリにインストールするスクリプトを実行する。

$ rails generate react:install
      create  app/assets/javascripts/components
      create  app/assets/javascripts/components/.gitkeep
      insert  app/assets/javascripts/application.js
      insert  app/assets/javascripts/application.js
      insert  app/assets/javascripts/application.js
      create  app/assets/javascripts/components.js

components ディレクトリが作られたり、application.js に何か書き加えられたらしき結果が出ている。少し見てみる。

application.js に以下が追加されている。

//= require react
//= require react_ujs
//= require components

ココで読み込まれている「components」は、同ディレクトリの components.js のことらしい。中を見てみると以下のようになっている。

//= require_tree ./components

要するにさっき作られた、今のところまだ空の components ディレクトリ配下のファイルを全て読み込む、という話らしい。

React コンポーネントの作成

では、React コンポーネントを作ってみる。

React コンポーネントを生成してくれるコマンドがあるのでコレを使う。

$ rails generate react:component Film
      create  app/assets/javascripts/components/film.js.jsx

作られた film.js.jsx はこんな風になっている。

var Film = React.createClass({

  render: function() {
    return <div />;
  }
});

空 div を吐くだけだけど、最初から React コンポーネントの雛形が作られている。

今回は最終的に、映画情報の一覧ページを作ってみたい。

まずは静的データを表示する

views/films/index.html.erb の中身を以下の1行にする。

<%= react_component('Film') %>

これで、react-rails が app/assets/javascripts/components/film.js.jsx に書かれている Film という React のコンポーネントクラスを特定して出力してくれる。Rails の render 相当ということだろう。

先ほどの film.js.jsx に、まずは固定文言だけ書いてみる。

var Film = React.createClass({
  render: function() {
    return (
      <div className="film">
        MyCinemaReview : React-Rails Sample.
      </div>
    );
  }
});

こんな風に書き換えた。JSX はほとんど HTML そのままを JS コードに混ぜ込んで書くことができる。class 属性は予約語と衝突するためか、className として書く必要がある。

あとは rails server を起動すると、画面表示されるようになっている。

rails s でサーバを起動した状態で react-rails のインストールやコンポーネントの追加などをしていたら「NoMethodError」となってしまったので、インストール手順に問題がない場合はサーバを一旦落として再起動してみると良いかも。

固定データを渡してデータバインディングをやってみる

index.html.erb に以下のように書くことで、React の prop にデータを渡せる。ここでは「films」という名前で、映画情報の Hash を渡している。

<%= react_component('Film',
  films: [
    { title: 'Test 1', release_date: '2010-01-01' },
    { title: 'Test 2', release_date: '2010-11-11' }
  ]
) %>

film.js.jsx の方はこんな風にすると、films からデータを読み取って、リストを並べて表示させることができた。

// 親コンポーネント
var Film = React.createClass({
  render: function() {
    return (
      <div className="film">
        <h1>MyCinemaReview React-Rails Sample</h1>
        <FilmList films={this.props.films} />
      </div>
    );
  }
});

// 一覧を構成する子コンポーネント
var FilmList = React.createClass({
  render: function() {
    // Hash の1要素を構成するまとまりを作る
    var filmNodes = this.props.films.map(function(film) {
      return [
        <FilmListDt title={film.title} />,
        <FilmListDd release_date={film.release_date} />
      ];
    });
    
    // 一覧を返却する
    return (
      <dl>
        {filmNodes}
      </dl>
    );
  }
});

// dt 要素
var FilmListDt = React.createClass({
  render: function() {
    return (
      <dt>{this.props.title}</dt>
    );
  }
})

// dd 要素
var FilmListDd = React.createClass({
  render: function() {
    return (
      <dd>
        <ul>
          <li>公開日 : {this.props.release_date}</li>
        </ul>
      </dd>
    );
  }
})

React.js を知らないと、「なんで dt 要素と dd 要素は filmNodes で一気に書けないの?」と思ったと思う。ぼくもそう思った。最初以下のように書いたらエラーになってしまった。

// これは2つのタグ = コンポーネントを返しているのでエラーになる
var filmNodes = this.props.films.map(function(film) {
  return (
    <dt>
      {film.title}
    </dt>
    <dd>
      {film.release_date}
    </dd>
  );
});

要するに、return する「ルートの要素 = コンポーネント」は1つのみという決まりがあるらしく、同階層に複数の要素があってはいけない、ということなのだ。

返却する要素が1つなら良いので、HTML としてイケてないのは承知の上で、もしも以下のように書いたとしたらエラーにはならない。

// 一覧を構成する子コンポーネント
var FilmList = React.createClass({
  render: function() {
    var filmNodes = this.props.films.map(function(film) {
      return (
        <dl>
          <dt>{film.title}</dt>
          <dd>{film.release_date}</dd>
        </dl>
      );
    });
    
    // 一覧を返却する
    return (
      <div>
        {filmNodes}
      </div>
    );
  }
});

これなら、FilmList としては div を返し、その中に連想配列の要素分だけ filmNodes が返した dl 要素が並ぶことになる。HTML 的には dl 要素は1つでいいので、dt要素と dd 要素をそれぞれ返す、こういう書き方になった。

JSON でデータを返すコントローラを作る

JSON を返すだけのコントローラを作る。JS や CSS は不要なので、--no-assets オプションを指定する。

$ rails g controller api/films --no-assets
      create  app/controllers/api/films_controller.rb
      invoke  erb
      create    app/views/api/films
      invoke  test_unit
      create    test/controllers/api/films_controller_test.rb
      invoke  helper
      create    app/helpers/api/films_helper.rb
      invoke    test_unit

app/assets/controllers/api/films_controller.rb が出来ているので、以下のように実装する。さっき index.html.erb に書いていた Hash 部分をそのまま移植した状態。

class Api::FilmsController < ApplicationController
  def index
    @films = [
      { title: 'Test 1', release_date: '2010-01-01' },
      { title: 'Test 2', release_date: '2010-11-11' }
    ]
  end
end

次に app/views/api/films/index.json.jsonbuilder を作り、以下のように実装する。

# JBuilder の中ではシャープでコメントアウトできる (Ruby としてパースするのだろう)
# コントローラから受け取った @films を1行ずつ処理している
json.data(@films) { |film| json.extract!(film, :title, :release_date) }

あとはルーティング。config/routes.rb に以下を追加する。

# Films の JSON データを返す API のルーティング
namespace :api, format: 'json' do
  resources :films
end

これで rake routes をしてルーティングを反映させる。

$ rake routes
       Prefix Verb   URI Pattern                   Controller#Action
        films GET    /films(.:format)              films#index
              POST   /films(.:format)              films#create
     new_film GET    /films/new(.:format)          films#new
    edit_film GET    /films/:id/edit(.:format)     films#edit
         film GET    /films/:id(.:format)          films#show
              PATCH  /films/:id(.:format)          films#update
              PUT    /films/:id(.:format)          films#update
              DELETE /films/:id(.:format)          films#destroy
# ココから下が今回追加した api/films のルーティング
    api_films GET    /api/films(.:format)          api/films#index {:format=>/json/}
              POST   /api/films(.:format)          api/films#create {:format=>/json/}
 new_api_film GET    /api/films/new(.:format)      api/films#new {:format=>/json/}
edit_api_film GET    /api/films/:id/edit(.:format) api/films#edit {:format=>/json/}
     api_film GET    /api/films/:id(.:format)      api/films#show {:format=>/json/}
              PATCH  /api/films/:id(.:format)      api/films#update {:format=>/json/}
              PUT    /api/films/:id(.:format)      api/films#update {:format=>/json/}
              DELETE /api/films/:id(.:format)      api/films#destroy {:format=>/json/}

これで http://localhost:3000/api/films.json にアクセスすると JSON データが取れるはず。

React で JSON データを受け取って表示させる

こうやって作った films.json を、React で扱えるようにしていく。

まず React を表示させていた app/views/films/index.html.erb で以下のように書き換える。

<%= react_component('Film', url: '/api/films.json') %>

今のところ、React には URL を渡しているだけなので、この URL にアクセスし、受け取った JSON データを子コンポーネントに受け渡すよう、親コンポーネントを直す。

ここでstateという概念が出てくる。
既に出てきているpropsは、これまで必ず親コンポーネントから渡されている。
propsの値は親Componentで管理するので、決してReactのComponentの中で変更してはいけない。
これを守ることで、ReactのComponentは同じpropsを渡される限りは、必ず同じレンダリング結果になるので、immutableなComponentとして扱えることになる。これ重要!
じゃあ親Componentでは値の変更はどうするの、というとこれはstateという、Componentの状態を保持するための変数に適用する。

ということで、app/assets/javascripts/components/film.js.jsx の親コンポーネントを以下のように直す。

// 親コンポーネント
var Film = React.createClass({
  // State の初期値設定
  getInitialState: function() {
    // films には空の配列を渡して初期化しておく。これが {this.state.films} でアクセスできる State になる
    return {
      films: []
    };
  },
  // コンポーネントが最初にレンダリングされた時の処理
  componentDidMount: function() {
    // State {this.state.films} となるデータを取得する
    $.ajax({
      url: this.props.url,
      dataType: 'json',
      success: function(result) {
        // State {this.state.films} をセットする
        this.setState({
          films: result.data
        });
      }.bind(this),
      error: function(xhr, status, err) {
        console.error(this.props.url, status, err.toString());
      }.bind(this)
    });
  },
  render: function() {
    return (
      <div className="film">
        <h1>MyCinemaReview React-Rails Sample</h1>
        <FilmList films={this.state.films} />
      </div>
    );
  }
});

getInitialState()componentDidMount() は React で決まっている function みたい。ココでそれぞれ初期値の設定と Ajax でのデータ取得とセットを行っている。

render() 内は一箇所だけ、<FilmList /> に渡すデータを {this.props.films} から {this.state.films} に変更している。

子コンポーネントを修正していないのが特徴。親コンポーネントでやり方を変えるだけで良い、というワケだ。

定期的にポーリングする

これだと初回アクセスでしかレンダリングされないので、定期的にポーリングするように書き換える。

まずは app/views/films/index.html.erb でポーリングの間隔を指定する。

<%# Ajax で JSON を取得できるよう URL を渡し、ポーリングする間隔を指定する %>
<%= react_component('Film', url: '/api/films.json', pollInterval: 2000) %>

次に app/assets/javascripts/components/film.js.jsx を以下のように書き換える。

// 親コンポーネント
var Film = React.createClass({
  // State {this.state.films} となるデータを取得する
  loadFilms: function() {
    $.ajax({
      url: this.props.url,
      dataType: 'json',
      success: function(result) {
        // State {this.state.films} をセットする
        this.setState({
          films: result.data
        });
      }.bind(this),
      error: function(xhr, status, err) {
        console.error(this.props.url, status, err.toString());
      }.bind(this)
    });
  },
  // State の初期値設定
  getInitialState: function() {
    // 同じなので省略
  },
  // コンポーネントが最初にレンダリングされた時の処理
  componentDidMount: function() {
    // 初回読み込み
    this.loadFilms();
    // 指定の間隔でポーリングする
    setInterval(this.loadFilms, this.props.pollInterval);
  },
  render: function() {
    // 同じなので省略
  }
});

componentDidMount() 内にいた $.ajax 部分を、適当な function として外出しした。componentDidMount() で、初回の読み込みと、定期的なポーリングのために setInteval() を設定している。

実際にデータを取得して表示させる

ようやく実際に DB からデータを取得するところに移る。

app/controllers/films_controller.rb に実装していた内容を app/controllers/api/films_controller.rb に移植してやれば良いだろう。

class Api::FilmsController < ApplicationController
  # IndexAction
  # JSON でデータを返す
  def index
    # Model の all() で全件取得する
    @films = Film.all
  end
end

app/controllers/api/films_controller.rb は空にしてしまう。

class FilmsController < ApplicationController
  # IndexAction
  def index
    # Do Nothing
  end
end

これでトップページができた。

所感

既存の Rails アプリのフロントエンドを、これから React.js に移植していく、といった場面であれば、この RubyGems を使うのは効果的かもしれない。

ゼロから Rails アプリを作ろうとする時は、アプリの規模・開発形態にもよるが、この Gem を使わず、フロントエンド周りは npm で導入し、Webpack での管理に統一してやった方が、フロントエンドとバックエンドが分離できて良いかもしれない。

参考

日本語で React と Rails の組み合わせという記事になると、React 単体、Rails 単体をそれぞれよく知っている人が書いている記事が多い気がして、ぼくみたいな初心者が入門として参考にできる記事がなかなかなかった。それぞれの記事を何度も読んでやってみて、ようやくなんとかこの辺までこぎつけたという感じ。