react-railsを使ってReactのTutorialをやってみる

  • 449
    いいね
  • 6
    コメント

はじめに

react-railsという、ReactをAsset Pipelineに乗せて使えるようにしてくれるruby gemsがある。

この記事では、これを使用してReactの公式tutorialを進めていく。

公式のtutorialではサーバ側はすでに実装済みとして進んでいる。
今回はせっかくなのでtutorialを進める中でサーバ側も実装していくことにする。

ちなみに使ってみた個人的な感想としては、RailsのAsset Pipelineに乗せるならすごく順当かなー、という感じ。
browserifyを使いたい。bowerもオワコンと呼ばれてしまう。そんなこの時代。
Asset Pipelineは捨てたい感じがあるけど、既存のアプリケーションを徐々にReactコンポーネント化していく、みたいな時には良いと思う。

方針

  • きまぐれで、必要そうなところはReactの説明する。けど英訳とかはしない。
  • 章立ては公式Tutorialと揃えているので、併せて読み進めると理解しやすいはず。
  • 全くReactの概念が分からない人は、一人React.js Advent Calendar 2014という素晴らしい記事がある。読んでおくときっと理解しやすい。

元となっている資料

おすすめの記事

環境

  • ruby 2.2.0
  • rails 4.2.1
  • react-rails 1.0.0

github

https://github.com/joe-re/react-rails-tutorial

コミットの単位を章立てに合わせているので、tigとかで差分を見ながら進めるとやりやすいかも知れない。

tutorial

下準備

rails newでプロジェクトを作成。

$ rails new react-rails-tutorial
$ cd react-rails-tutorial

react-railsを追加。

$ vi Gemfile
# ...省略
gem 'react-rails', '~> 1.0' # 追加する

$ bundle install

react-railsにはreactを使えるようによしなにしてくれるgeneratorが用意されている。叩く。

$ rails g react:install

react-railsにはReactのビルドオプションを設定することができる。tutorialではいずれも指定の必要はないので飛ばす。参考

Your first component

react-railsにはComponent定義を生成してくれるgeneratorがある。これを叩いて最初のComponentを作る。

rails g react:component CommentBox

app/assets/javascripts/components/comment_box.js.jsxが作られる。

中身を書き変える。

app/assets/javascripts/components/comment_box.js.jsx
var CommentBox = React.createClass({
  render: function() {
    return (
      <div className="commentBox">
        Hello, world! I am a CommentBox.
      </div>
    );
  }
});

Railsのcontrollerとviewを作成

generatorを使って、Controllerを作成する。

$ rails g controller comments

viewを作成する。

app/views/comments/index.html.erb
<%= react_component('CommentBox') %>

react-railsをインストールすると、react_componentというhelperメソッドが追加される。
この第一引数に、ここで使うReact Componentを文字列で渡すと、ここにrenderしてくれる。

ルーティングを設定する。

config/routes.rb
root 'comments#index'

rails serverでサーバを立ち上げて、http://localhost:3000にアクセスしてみる。
「Hello, world! I am a CommentBox.」と表示されればok。

Composing components

comment_box.js.jsxに子Componentの定義を追加する。

app/assets/javascripts/components/comment_box.js.jsx
// ...省略

var CommentList = React.createClass({
  render: function() {
    return (
      <div className="commentList">
        Hello, world! I am a CommentList.
      </div>
    );
  }
});

var CommentForm = React.createClass({
  render: function() {
    return (
      <div className="commentForm">
        Hello, world! I am a CommentForm.
      </div>
    );
  }
});

追加したモジュールを使うようにCommentBoxを修正。

app/assets/javascripts/components/comment_box.js.jsx
var CommentBox = React.createClass({
  render: function() {
    return (
      <div className="commentBox">
        <h1>Comments</h1>
        <CommentList />
        <CommentForm />
      </div>
    );
  }
});

# ...省略

最終的はこうなる。

app/assets/javascripts/components/comment_box.js.jsx
var CommentBox = React.createClass({
  render: function() {
    return (
      <div className="commentBox">
        <h1>Comments</h1>
        <CommentList />
        <CommentForm />
      </div>
    );
  }
});

var CommentList = React.createClass({
  render: function() {
    return (
      <div className="commentList">
        Hello, world! I am a CommentList.
      </div>
    );
  }
});

var CommentForm = React.createClass({
  render: function() {
    return (
      <div className="commentForm">
        Hello, world! I am a CommentForm.
      </div>
    );
  }
});

この時点でブラウザからアクセスしてみると、こうなっている。

Screenshot 2015-04-28 02.32.03.png

Using props

ここではReactのpropsを使って、Component間で値の受け渡しをする。

まずはcomment_box.js.jsxに子Componentの定義を追加する。

app/assets/javascripts/components/comment_box.js.jsx
// ... 省略
var Comment = React.createClass({
  render: function() {
    return (
      <div className="comment">
        <h2 className="commentAuthor">
          {this.props.author}
        </h2>
        {this.props.children}
      </div>
    );
  }
});

JSXの中では、{}で囲うことでComponentの保持している変数にアクセスできる。
ここではthis.props.authorthis.props.childrenにアクセスしている。
this.props.hogeというのがComponentで保持しているpropsへのアクセス方法で、この値は親Componentから渡される。

Component Properties

comment_box.js.jsxのCommentListを、先ほど定義したCommentを使用するように修正する。

app/assets/javascripts/components/comment_box.js.jsx
// ... 省略
var CommentList = React.createClass({
  render: function() {
    return (
      <div className="commentList">
        <Comment author="Pete Hunt">This is one comment</Comment>
        <Comment author="Jordan Walke">This is *another* comment</Comment>
      </div>
    );
  }
});
// ... 省略

ここでauthor="Pete Hunt"と書いているところがReactのpropsの受け渡し部分。こうすると、Comment Componentでは、this.props.authorでここで渡された値にアクセスできる。

この時点でhttp://localhost:3000にアクセスすると、こうなる。

Screenshot 2015-04-28 02.51.12.png

Adding Markdown

markedを使って、CommentをMarkdown形式で書けるようにする。

$ vi Gemfile
# ...省略
gem 'marked-rails' # 追加

$ bundle install
app/assets/javascripts/application.js
// ...省略
//= require marked # 追加

Comment Componentでmarkdownを変換するように書き換える。

app/assets/javascripts/components/comment_box.js.jsx
var Comment = React.createClass({
  render: function() {
    return (
      <div className="comment">
        <h2 className="commentAuthor">
          {this.props.author}
        </h2>
        {marked(this.props.children.toString())}
      </div>
    );
  }
});

このままだと、htmlタグがエスケープして表示される。
これはReactが僕らを危険なコードから守ってくれているからだ。

今回は信頼のおけるコードとして、レンダリングして欲しいので下記のようにする。

app/assets/javascripts/components/comment_box.js.jsx
var Comment = React.createClass({
  render: function() {
    var rawMarkup = marked(this.props.children.toString(), {sanitize: true});
    return (
      <div className="comment">
        <h2 className="commentAuthor">
          {this.props.author}
        </h2>
        <span dangerouslySetInnerHTML={{__html: rawMarkup}} />
      </div>
    );
  }
});

そうすると意図通りにレンダリングされる。

Screenshot 2015-04-28 03.05.40.png

Hook up the data model

最終的にはサーバからJSONを取得してクライアントでレンダリングするけど、とりあえず現段階ではview(erb)に埋め込んで渡す。

react-railsでは、react_componentメソッドの第2引数にrubyのhashを渡すことで、reactのpropとしてComponentに渡せる。

app/views/comments/index.html.erb
<%= react_component('CommentBox',
  data: [
    {author: "Pete Hunt", text: "This is one comment"},
    {author: "Jordan Walke", text: "This is *another* comment"}
  ]
) %>

埋め込んだdataを使うように、CommentBoxとCommentListを書き換える。

app/assets/javascripts/components/comment_box.js.jsx
var CommentBox = React.createClass({
  render: function() {
    return (
      <div className="commentBox">
        <h1>Comments</h1>
        <CommentList data={this.props.data} />
        <CommentForm />
      </div>
    );
  }
});

var CommentList = React.createClass({
  render: function() {
    var commentNodes = this.props.data.map(function (comment) {
      return (
        <Comment author={comment.author}>
          {comment.text}
        </Comment>
      );
    });
    return (
      <div className="commentList">
        {commentNodes}
      </div>
    );
  }
});

localhost:3000にアクセスして、無事ReactのComponentにdataが渡せてレンダリングされていることが確認できればok。

Fetching from the server

先ほどviewに埋め込んだJSONをサーバから受け取るようにする。

まずWebAPI用のコントローラを作成。jsonを返すWebAPIなのでjavascriptやstylesheetは要らない。--no-assetを指定する。

$ rails g controller api/v1/comments --no-assets

Controllerを実装する。

app/controllers/api/v1/comments_controller.rb
class Api::V1::CommentsController < ApplicationController
  def index
    @data = [
      { author: 'Pete Hunt', text: 'This is one comment' },
      { author: 'Jordan Walke', text: 'This is *another* comment' }
    ]
  end
end

jsonを返すviewを定義する。

app/views/api/v1/comments/index.json.jbuilder
json.data(@data) { |d| json.extract!(d, :author, :text) }

routeを定義する。

config/routes.rb
# ...省略
namespace :api, format: 'json' do
  namespace :v1  do
    resources :comments
  end
end

この段階で http://localhost:3000/api/v1/comments.json にアクセスしてみて、JSONが返却されればok。

CommentBoxにはこのAPIのurlを渡すようにする。

app/views/comments/index.html.erb
<%= react_component('CommentBox', url: '/api/v1/comments.json') %>

まだurlを渡すようにしただけで、APIにはアクセスしていない。
現段階でhttp://localhost:3000にアクセスすると、エラーになる。

Reactive state

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

じゃあ親Componentでは値の変更はどうするの、というとこれはstateという、Componentの状態を保持するための変数に適用する。

下記の記事を読むとよく分かる。

CommentBoxにおいては、urlがpropsでWebAPIで取得したdataはstateになる。

stateはgetInitialState()というメソッドで初期値を与えられる。
CommentBoxを、dataの初期値には空配列を与えて、CommentListにはthis.state.dataを与えるように変更する。

app/assets/javascripts/components/comment_box.js.jsx
var CommentBox = React.createClass({
  getInitialState: function() {
    return {data: []};
  },
  render: function() {
    return (
      <div className="commentBox">
        <h1>Comments</h1>
        <CommentList data={this.state.data} />
        <CommentForm />
      </div>
    );
  }
});
# ...省略

こうするとhttp://localhost:3000にアクセスした時のエラーは消える。ただしまだWebAPIにはアクセスしていないので、Commentは空になる。

Updating state

ReactのComponent内で使えるcomponentDidMount()というメソッドがある。これはReactのComponentが最初にrenderされる時に時に呼び出される。
このメソッド内でWebAPIにアクセスして、データを取得するように実装する。

app/assets/javascripts/components/
var CommentBox = React.createClass({
  getInitialState: function() {
    return {data: []};
  },
  componentDidMount: function() {
    $.ajax({
      url: this.props.url,
      dataType: 'json',
      success: function(result) {
        this.setState({data: result.data});
      }.bind(this),
      error: function(xhr, status, err) {
        console.error(this.props.url, status, err.toString());
      }.bind(this)
    });
  },
  render: function() {
    return (
      <div className="commentBox">
        <h1>Comments</h1>
        <CommentList data={this.state.data} />
        <CommentForm />
      </div>
    );
  }
});
// ....省略

これで/api/v1/comments.jsonにアクセスして取得したJSONを元にcomponentがrenderできるようになる。

this.setState()メソッドを用いてstateを変更していることに注目する。stateを変更する時は、必ずthis.setState()メソッドで変更しなければならない。
Reactはこのメソッドを通じてstateが変更された時に、Componentを再度renderする。

このままではページを開いた時にしかrenderされないので、簡単なpollingを実装してリアルタイムに値の変更が適用されるようにする。

ComponentBoxの呼び出し時にpollIntervalを与えるように修正。

app/views/comments/index.html.erb
<%= react_component('CommentBox', url: '/api/v1/comments.json', pollInterval: 2000) %>

WebAPIにアクセスする処理をloadCommentsFromServer()というメソッドに切り出す。
componentDidMount()メソッドで渡されたpollInterval間隔でloadCommentsFromServer()メソッドを呼び出すようにする。

app/assets/javascripts/components/comment_box.js.jsx
var CommentBox = React.createClass({
  loadCommentsFromServer: function() {
    $.ajax({
      url: this.props.url,
      dataType: 'json',
      success: function(result) {
        this.setState({data: result.data});
      }.bind(this),
      error: function(xhr, status, err) {
        console.error(this.props.url, status, err.toString());
      }.bind(this)
    });
  },
  getInitialState: function() {
    return {data: []};
  },
  componentDidMount: function() {
    this.loadCommentsFromServer();
    setInterval(this.loadCommentsFromServer, this.props.pollInterval);
  },
  render: function() {
    return (
      <div className="commentBox">
        <h1>Comments</h1>
        <CommentList data={this.state.data} />
        <CommentForm />
      </div>
    );
  }
});
// ...省略

http://localhost:3000にアクセスして、開発者ツールでネットワークキャプチャしてみると、2秒毎にアクセスしているのが見られる。

サーバを実装する

ここはtutorialには無い内容。

現在のところ、実装したWebAPIは固定のJSONを返却している。
データベースにアクセスしてJSONを返却できるように実装する。

modelの作成

$ rails g model comment author:string text:text
$ rake db:migrate

controllerの実装

get
app/controller/api/v1/comments_controller.rb
class Api::V1::CommentsController < ApplicationController
  def index
    @data = Comment.all
  end
end

データベースからアクセスしてjsonを返却するようにした。

まだデータがないので、localhost:3000にアクセスしても1件もCommentは表示されない。

rails consoleよりデータを作成してみる。

$ rails c
irb(main):001:0> Comment.create!({author: 'Pete Hunt',text: 'This is one comment'})
   (0.1ms)  begin transaction
  SQL (0.4ms)  INSERT INTO "comments" ("author", "text", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["author", "Pete Hunt"], ["text", "This is one comment"], ["created_at", "2015-04-29 07:12:53.220217"], ["updated_at", "2015-04-29 07:12:53.220217"]]
   (0.7ms)  commit transaction
=> #<Comment id: 1, author: "Pete Hunt", text: "This is one comment", created_at: "2015-04-29 07:12:53", updated_at: "2015-04-29 07:12:53">

再度localhost:3000にアクセスすると、作成したCommentが表示される。

post

次にCommentを作成できるAPIを実装する。

tutorialのサーバは、getしようとpostしようとcomment全件を配列で返す!というちょっと乱暴な感じで実装されている。

それはどうかなー、という感じがするので、POSTした時は作成したComment 1件を返す、という実装にする。

まずは返却するjsonを定義する。

app/views/api/v1/comments/show.json.jbuilder
json.extract!(@comment, :author, :text)

次にcreateアクションを実装する。

app/controller/api/v1/comments_controller.rb
class Api::V1::CommentsController < ApplicationController
  # ...省略

  def create
    @comment = Comment.create(comment_params)
    render :show, status: :created
  end

  private

  def comment_params
    params.permit(:author, :text)
  end
end

試しにcurlでPOSTを投げてCommentを作成したい。そのためにもう一箇所変更する。

RailsではCSRFの対策が入っていて、CSRF tokenが一致しない場合には例外を投げるという動作をする。

すげー便利!なんだけど今回はセッションの認証とかないのでこの動作を、一致しない場合はセッションを空にする、に変更する。

app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  protect_from_forgery with: :null_session
end

デフォルトではprotect_from_forgery with: :exceptionになっているはず。これを:null_sessionに変える。

curlでPOSTを投げる。

$ curl -X POST http://localhost:3000/api/v1/comments.json -H 'content-type: application/json' -d '{"author": "Jordan Walke", "text": "This is *another* comment"}'

http://localhost:3000にアクセスして、Commentが作成されてればok。

Adding new comments

CommentFormにCommentを入力できるFormを実装する。

app/assets/javascripts/components/comment_box.js.jsx
// ...省略
var CommentForm = React.createClass({
  render: function() {
    return (
      <form className="commentForm">
        <input type="text" placeholder="Your name" />
        <input type="text" placeholder="Say something..." />
        <input type="submit" value="Post" />
      </form>
    );
  }
});
// ...省略

次にFormをsubmitした時の動作を実装する。

app/assets/javascripts/components/comment_box.js.jsx
// ...省略
var CommentForm = React.createClass({
  handleSubmit: function(e) {
    e.preventDefault();
    var author = React.findDOMNode(this.refs.author).value.trim();
    var text = React.findDOMNode(this.refs.text).value.trim();
    if (!text || !author) {
      return;
    }
    // TODO: send request to the server
    React.findDOMNode(this.refs.author).value = '';
    React.findDOMNode(this.refs.text).value = '';
    return;
  },
  render: function() {
    return (
      <form className="commentForm" onSubmit={this.handleSubmit}>
        <input type="text" placeholder="Your name" ref="author" />
        <input type="text" placeholder="Say something..." ref="text" />
        <input type="submit" value="Post" />
      </form>
    );
  }
});
// ...省略

submitイベントが発行された時に、handleSubmit()を実行するようにした。

実装したのはsubmitされた時にauthorとtextの入力値にアクセスできるようにしたのと、イベント終了時にinputを空にするところ。

まだサーバへPOSTはしていない。ここで重要なのはinputの属性でref="author"と指定しているところ。

こうしておくと、ReactのComponentの中でthis.refs.hogeで要素にアクセスできるようになる。

次にサーバへPOSTを投げてCommentを作成する処理を実装したい、がその前にその処理がどこで実行されるべきか、ということについて考える。

今回実装しているComponentの構成は以下になる。

CommentBox
├── CommentList
│   └── Comment
└── CommentForm

Commentが作成された時に実際に増えるのは、CommentListの管理しているCommentで、それはCommentBoxのstateが渡されている。

app/assets/javascripts/components/comment_box.js.jsx
var CommentBox = React.createClass({
// ...省略
  render: function() {
    return (
      <div className="commentBox">
        <h1>Comments</h1>
        <CommentList data={this.state.data} /> // ここ!
        <CommentForm />
      </div>
    );
  }
});
// ...省略

つまりデータの大元はCommentBoxで保持しているstateになる。

Reactでは、データの変更ができるのはstateのみで、propsは決して直接いじってはいけない。(これ重要!)

今回はPOSTした後にはすぐに画面をリフレッシュしたいので、stateをいじる必要がある。

ということで、stateを持っているCommentBoxに処理を実装するのが正しい。

まずはCommentFormのsubmitイベントが実行された時に、CommentBoxに実装されたメソッドが呼ばれるようにする。

CommentBoxからCommentFormにcallbackを渡すことでこれを実現する。

app/assets/javascripts/components/comment_box.js.jsx
var CommentBox = React.createClass({
  // ...省略
  handleCommentSubmit: function(comment) {
    // TODO: submit to the server and refresh the list
  },
  // ...省略
  render: function() {
    return (
      <div className="commentBox">
        <h1>Comments</h1>
        <CommentList data={this.state.data} />
        <CommentForm onCommentSubmit={this.handleCommentSubmit} /> //ここでcallback渡す!
      </div>
    );
  }
});

// ...省略

var CommentForm = React.createClass({
  handleSubmit: function(e) {
    e.preventDefault();
    var author = React.findDOMNode(this.refs.author).value.trim();
    var text = React.findDOMNode(this.refs.text).value.trim();
    if (!text || !author) {
      return;
    }
    this.props.onCommentSubmit({author: author, text: text}); // ここでcallback実行する!
    React.findDOMNode(this.refs.author).value = '';
    React.findDOMNode(this.refs.text).value = '';
    return;
  },
  // ...省略
});

これでcallbackは渡せた。試しにhandleCommentSubmit()メソッドでconsole.logしてみれば、ちゃんとメソッドが呼び出されていることが確認できるはず。

handleCommentSubmit()にajaxでPOSTを投げる処理を実装する。

var CommentBox = React.createClass({
  // ...省略
  handleCommentSubmit: function(comment) {
    $.ajax({
      url: this.props.url,
      dataType: 'json',
      type: 'POST',
      data: comment,
      success: function(data) {
        this.setState({data: this.state.data.concat([data])});
      }.bind(this),
      error: function(xhr, status, err) {
        console.error(this.props.url, status, err.toString());
      }.bind(this)
    });
  },
  // ...省略
});

POSTのレスポンスのbodyには作成したCommentが返却されるように実装したので、

this.setState({data: this.state.data.concat([data])});

で返却されたCommentをstateに追加するように実装している。

ここでArray#concatを使っているのは、変更前に保持していたstateを直接変更したくないため。

下記の記事が非常に分かりやすい。

React.jsのState

Optimization: optimistic updates

ここはsubmitが実行された時にサーバ通信する前にstateを変更することで、ユーザへのフィードバックを早めよう!という内容。

今回はPOST時のレスポンスをtutorialと変えているのでそのまま適用できないけど、やるとしたらこんな感じ。

app/assets/javascripts/components/comment_box.js.jsx
var CommentBox = React.createClass({
  // ...省略
  handleCommentSubmit: function(comment) {
    this.setState({data: this.state.data.concat([comment])});
    $.ajax({
      url: this.props.url,
      dataType: 'json',
      type: 'POST',
      data: comment,
      success: function(data) {
      }.bind(this),
      error: function(xhr, status, err) {
        console.error(this.props.url, status, err.toString());
      }.bind(this)
    });
  },
  // ...省略
});

successの処理を頭に持ってきただけ。
でもこれって短い間(最大2秒)とはいえ、失敗した時には間違った結果がrenderされていることになる。どうなんだろう。

Congrats!

おわりです。お疲れ様でした!