grails-react-boilerplate で React に入門した - 2日目 React-Router, サーバー通信

前回、ホットリロードに対応して、React-Bootstrapを使ってナビゲーションバーを実装(見た目だけ)するところまでやりました。

f:id:bati11:20151231110319p:plain

今回は、ナビゲーションバーをクリックすることで画面を切り替えるようにします。

前回に引き続き、以下の記事とリポジトリを参考に進めます。

uehaj.hatenablog.com

github.com

React-Router

ナビゲーションバーの「Link1」「Link2」でそれぞれ画面を切り替えます。URLで表示するReactコンポーネントを切り替えることで対応します。これはReact-Routerを使うと実現できます。

$ npm install history react-router --save-dev

ReactDOM.renderの第1引数をReact-Routerのコンポーネントに書き換えます。


追記 2016/2/21

React Router 2.x系からhistoryの部分の書き方が変わったようです。

react-router/Histories.md at latest · reactjs/react-router · GitHub

以下のコードの var createBrowserHistory = require('history/lib/createBrowserHistory'); ではなく var browserHistory = require('react-router').browserHistory とします。

追記ここまで。


// react-app/src/index.js
var Router = require('react-router').Router;
var Route = require('react-router').Route;
var IndexRedirect = require('react-router').IndexRedirect;
var createBrowserHistory = require('history/lib/createBrowserHistory');

ReactDOM.render(
  <Router history={createBrowserHistory()}>
    <Route name="TOP" path="/" component={TopLevel}>
      <IndexRedirect from="*" to="link1" />
      <Route path="link1" component={Page1} />
      <Route path="link2" component={Page2} />
    </Route>
  </Router>,
  document.getElementById('root')
);

これで以下のようにルーティングされます。

TopLevelコンポーネントで子要素を表示するように変更します。{this.props.children}を使います。

// react-app/src/components/TopLevel.js
module.exports = React.createClass({
  render: function() {
    return (
      <div>
        <TopLevelNavBar />
        {this.props.children}
        <footer>footer</footer>
      </div>
    );
  }
});

適当にPage1コンポーネントを用意します。

// react-app/src/components/Page1.js
var React = require('react');

module.exports = React.createClass({
  render: function() {
    return (
      <div>
        Page1.
      </div>
    );
  }
});

同じようにPage2コンポーネントも用意します。

server.jsにあるwebpack-dev-serverの設定にhistoryApiFallback: trueを追加。

// react-app/server.js
new WebpackDevServer(webpack(config), {
  ・・・
  historyApiFallback: true
}).listen(3000, 'localhost', function (err, result) {
  ・・・

webpack-dev-serverを再起動してアクセス。ナビゲーションバーのリンクをクリックすると表示されるコンポーネントが切り替わります!

しかし、画面のリロードが発生してしまってます!これは望んでた動きじゃないです。リロードなしで表示されるコンポーネントを切り替えたい!!

これは、リンク要素をReact RouterのLinkコンポーネントで実装することで実現できます。しかし、ナビゲーションバーのリンク要素はReact-BootstrapのNavItemコンポーネントで実装しているため、Linkコンポーネントは使えません。こういう時に使うのがreact-router-bootstrap。

GitHub - react-bootstrap/react-router-bootstrap: Integration between React Router and React-Bootstrap

$ npm install react-router-bootstrap --save-dev

react-router-bootstrapのLinkContainerコンポーネントでNavItem コンポーネントを囲みます。

// react-app/src/components/TopLevel.js
var LinkContainer = require('react-router-bootstrap').LinkContainer;
・・・
<Nav>
  <LinkContainer to={"/link1"}><NavItem>Link1</NavItem></LinkContainer>
  <LinkContainer to={"/link2"}><NavItem>Link2</NavItem></LinkContainer>
</Nav>

これでブラウザのリロードなしでPage1コンポーネントとPage2コンポーネントの切り替えができるようになりました!

Dynamic Children と key

メニューのリンクの数はルーティングの設定から動的に取得できると嬉しいですね。this.props.route を使って取得できます。

この時Routeコンポーネントのpropsを使うと便利です。Routeコンポーネントnameというpropを追加します。

// react-app/src/index.js
<Route name="TOP" path="/" component={TopLevel}>
  <IndexRedirect from="*" to="link1" />
  <Route path="link1" name="page1" component={Page1} />
  <Route path="link2" name="page2" component={Page2} />
</Route>

TopLevelコンポーネントで子供のRouteコンポーネントの一覧をthis.propts.routeを使って取得します。

// react-app/src/components/TopLevel.js
var TopLevel = React.createClass({
        ・・・
        <TopLevelNavbar route={this.props.route} />
        ・・・
});

var TopLevelNavbar = React.createClass({
        ・・・
        <Nav>
          {this.props.route.childRoutes.map(function(item) {
            return (
              <LinkContainer to={"/"+item.path}><NavItem>{item.name}</NavItem></LinkContainer>
            )
          })}
        </Nav>
        ・・・
});

これでナビゲーションバーのリンクが増えた場合でも、Routeコンポーネントを増やすだけで対応できるようになりました。

しかし、コンソールを見てみると以下のような警告メッセージが出力されてます。

Warning: Each child in an array or iterator should have a unique "key" prop. Check the render method of `TopLevelNavBar`. See https://fb.me/react-warning-keys for more information.

動的にコンポーネントのリストを作るような時、keyというpropにユニークな値をつけておいた方が良いようです。こちらが参考になります。 React.jsの地味だけど重要なkeyについて - Qiita

ユニークなkeyを設定しましょう。item.pathならユニークになります。これで警告メッセージも消えます。

// react-app/src/components/TopLevel.js
{this.props.route.childRoutes.map(function(item) {
  return (
    <LinkContainer key={item.path} to={"/"+item.path}><NavItem>{item.name}</NavItem></LinkContainer>
  )
})}

データ一覧表示

次はサーバーからデータを取得して表示します。

grails-react-boilerplateでは、Grailsでサーバーアプリケーションが作られています。起動するには以下のようにします。

$ ./grailsw
grails> run-app

ではまずは、BookIndexPageコンポーネントをつくって、それをルーティングに登録してみましょう。

// react-app/src/components/BookIndexPage.js
var React = require('react');

module.exports = React.createClass({
  render: function() {
    return (
      <div>
        <h1>BookIndexPage</h1>
      </div>
    );
  }
});
// react-app/src/index.js
var BookIndexPage = require('./components/BookIndexPage');

ReactDOM.render(
  <Router history={createBrowserHistory()}>
    <Route name="TOP" path="/" component={TopLevel}>
      <IndexRedirect from="*" to="link1" />
      <Route path="book" name="book-index" component={BookIndexPage} />
      <Route path="link1" name="page1" component={Page1} />
      <Route path="link2" name="page2" component={Page2} />
    </Route>
  </Router>,
  document.getElementById('root')
);

BookIndexPageコンポーネントができました。この中でBookListコンポーネントをつくってそこにサーバーから読み込んだデータを表示したいと思います。

react-bootstrap-tableを使ってテーブル要素をつくります。

React Bootstrap Table

$ npm install react-bootstrap-table --save-dev

データはstateで管理します。ひとまず、固定のデータで。

// react-app/src/components/BookIndexPage.js
var BootstrapTable = require('react-bootstrap-table').BootstrapTable;
var TableHeaderColumn = require('react-bootstrap-table').TableHeaderColumn;
require('react-bootstrap-table/css/react-bootstrap-table-all.min.css');

module.exports = React.createClass({
  getInitialState: function() {
    var data = [
      {"id": 1, "title": "hoge", "price": 200},
      {"id": 2, "title": "fuga", "price": 500}
    ];
    return {booklist: data};
  },
  render: function() {
    return (
      <div>
        <h1>Books</h1>
        <BootstrapTable data={this.state.booklist}
                        hover condensed pagination deleteRow
                        selectRow={{
                            mode: 'checkbox',
                            bgColor: "rgb(238, 193, 213)",
                        }}
                        >
          <TableHeaderColumn dataField="id" dataSort={true} isKey={true} >ID</TableHeaderColumn>
          <TableHeaderColumn dataField="title" dataSort={true}>Title</TableHeaderColumn>
          <TableHeaderColumn dataField="price" dataSort={true}>Price</TableHeaderColumn>
        </BootstrapTable>
      </div>
    );
  }
});

こんな感じになります。

f:id:bati11:20151231130941p:plain

では、サーバーから取得したデータを一覧表示する様にしましょう。サーバーとの通信処理は、ajax.jsという別ファイルに書きます(このへんの設計をFluxにするかどうかを検討するのかな?)。

// react-app/src/ajax.js
var $ = require('jquery');

var urlBase = '/api/';

exports.getBooks = function (callback, callbackError) {
  $.ajax({
    type: 'GET',
    url: urlBase + 'books.json?max=100',
    contentType: 'application/json',
    dataType: 'json',
    cache: false,
    success: function(data) {
      callback(data)
    },
    error: function(xhr, status, err) {
      console.error(xhr, status, err.toString())
      if (callbackError !== undefined) {
        callbackError(err)
      }
    }
  });
}

公式サイトのチュートリアルにも出てきたように、BookIndexPageコンポーネントに componentDidMount メソッドを追加して、サーバーからデータを取得します。

// react-app/src/components/BookIndexPage.js
var ajax = require('../ajax');

module.exports = React.createClass({
  getInitialState: function() {
    return {booklist: []};
  },
  componentDidMount: function() {
    var that = this;
    ajax.getBooks(function(data) {
      that.setState({ booklist: data });
    });
  },
  ・・・

server.jsにproxyの設定を追加して、ローカルで動いているGrailsアプリケーションのAPIを呼ぶようにします。

// react-app/server.js
new WebpackDevServer(webpack(config), {
  ・・・
  proxy: {
    '/api/*': "http://localhost:8080",
  },
}).listen(3000, 'localhost', function (err, result) {

server.jsを再起動して、アクセスするとサーバーから取得したデータが表示されます!

f:id:bati11:20151231132405p:plain

データ登録

データの登録フローをつくります。下の流れのようにします。

  • Newボタンをクリック -> ダイアログが表示される
  • ダイアログ内のフォームに入力して投稿 -> サーバーにデータが登録され、一覧に入力したデータが表示される

ボタンはReact-BootstrapのButtonコンポーネント、ダイアログはReact-BootstrapのModalコンポーネントを使います。では、BookNewDialogコンポーネントをつくりましょう。

// react-app/src/components/BookNewDialog.js
var React = require('react');

var Modal = require('react-bootstrap').Modal;
var Input = require('react-bootstrap').Input;
var Button = require('react-bootstrap').Button;

module.exports = React.createClass({
  callbackSubmitButtonAction: function() {
    this.props.submitButtonAction({
      title: this.refs.title.getValue(),
      price: this.refs.price.getValue()
    });
  },
  getInitialState: function() {
    return {book: null};
  },
  render: function() {
    return (
      <Modal show={this.props.show} onHide={this.props.closeAction}>
        <Modal.Header closeButton>
          <Modal.Title>New Book</Modal.Title>
        </Modal.Header>
        <Modal.Body>
          <form ref='form' className="form-horizontal">
            <Input ref="title" type="text" label="Title:" labelClassName="key col-xs-2" wrapperClassName="col-xs-10" defaultValue={this.state.book && this.state.book.title} />
            <Input ref="price" type="text" label="Price:" labelClassName="key col-xs-2" wrapperClassName="col-xs-10" defaultValue={this.state.book && this.state.book.price} />
          </form>
        </Modal.Body>
        <Modal.Footer>
          <Button bsStyle="primary" onClick={this.props.closeAction}>Close</Button>
          <Button bsStyle="success" onClick={this.callbackSubmitButtonAction}>Create</Button>
        </Modal.Footer>
      </Modal>
    );
  }
});

コンポーネントから受け取ったsubmitButtonActionやcloseActionをonClick時のアクションとして指定します。フォームの値はstateを使って管理します。

BookListコンポーネントのJSXを変更して、BookNewDialogコンポーネントを追加します。

// react-app/src/components/BookNewDialog.js
<Button onClick={this.showNewDialog}>New</Button>
<BootstrapTable data={this.state.booklist}
・・・
</BootstrapTable>
<BookNewDialog show={this.state.showNewDialog}
               closeAction={this.hideNewDialog}
               submitButtonAction={this.createBook} />

使用してるメソッドをつくっていきます。

// react-app/src/components/BookIndexPage.js
module.exports = React.createClass({
  createBook: function(creatingBook) {
    this.setState({showNewDialog: false});
    console.log(creatingBook);
  },
  showNewDialog: function() {
    this.setState({showNewDialog: true});
  },
  hideNewDialog: function() {
    this.setState({showNewDialog: false});
  },
  ・・・

ここまででダイアログを表示して、入力された値をcreateBookメソッドで受け取るところまでできました(ダイアログを表示するかどうかのstateを変えるのってFlux的にやるならどうなるんでしょうね)。

f:id:bati11:20151231133852p:plain

あとは、createBookメソッドでサーバーにPOSTすればいいですね。ajax.jsにメソッドを追加します。

// react-app/src/ajax.js
exports.createBook = function (book, callback, callbackError) {
  $.ajax({
    type: 'POST',
    url: urlBase + `books.json`,
    contentType: 'application/json',
    dataType: 'json',
    data: JSON.stringify(book),
    cache: false,
    success: (data) => {
      if (callback !== undefined) {
        callback(data)
      }
    },
    error: (xhr, status, err) => {
      console.error(xhr, status, err.toString())
      if (callbackError !== undefined) {
        callbackError(err)
      }
    }
  });
};

BookListコンポーネントから、ajaxモジュールのメソッドを呼び出すように変更します。thisが・・・。

// react-app/src/component/BookList.js
module.exports = React.createClass({
  createBook: function(creatingBook) {
    this.setState({showNewDialog: false});
    var that = this;
    ajax.createBook(creatingBook, function(){
      that.reloadData();
    }, function(err) {
      console.log("error");
    });
  },
reloadData: function() {
  var that = this;
  ajax.getBooks(function(data) {
    that.setState({ booklist: data });
  });
},
  ・・・

これでデータをサーバーに登録できて、登録したデータがすぐに一覧に追加されます。

おしまい

今回は、React-Router、Dynamic Children と key、サーバーとのHTTP通信をやりました。

grails-react-boilerplateではBabelを使ってES2015でJavaScriptを書けるように対応しています。次回、Babelを使うようにするのとESLintを入れてみます。