Help us understand the problem. What is going on with this article?

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

More than 3 years have passed since last update.

はじめに

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!

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

joe-re
freeeのエンジニアです
http://joe-re.hatenablog.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした