React + Redux で Web API を呼び出す

React + Redux で SPA を作成するとき避けられないのが、 Web API の呼び出し。 というか、Web API を呼び出さないなんて、まず考えられない。

Web API の呼び出しでは、redux-thunk を使う。

github.com

Redux のミドルウェアで、 Action Creator でオブジェクトだけでなく関数も返せるようになる。 HTTP リクエストを送るやつじゃない。 こいつを上手く使うことで、 Redux のレールの上でなんとか Web API を呼び出せた。

まず API サーバーは express を使って作成。 単純化するために API は GET のみ。 テンプレートエンジンを使うまでもないので、 HTML は静的ファイルで配信する。

var express = require("express");

// POST したデータをパースするために body-parser が別途必要
var bodyParser = require("body-parser");

var app = express();

// req.body をパースしてオブジェクトにするミドルウェアを使う
app.use(bodyParser.json());

// 静的なファイルを配信するミドルウェアを使う
app.use(express.static("public"));

var players = [
  { number: 10, name: "Kagawa" },
  { number: 4, name: "Honda" },
  { number: 5, name: "Nagatomo" },
  { number: 9, name: "Okazaki" }
];

app.get("/api/players", function(req, res) {
  res.send(players);
});

var server = app.listen(3000, function() {
  var host = server.address().address;
  var port = server.address().port;

  console.log("Listening at http://%s:%s", host, port);
});

HTML はこれだけ。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>React Project</title>
  </head>
  <body>
    <div id="content"></div>
    <script src="bundle.js"></script>
  </body>
</html>

クライアント側の JSX コード。 ポイントは関数を返す Action Creator と、 取得の開始・成功・失敗でアクションを分けているところ。 Action Cretor が返した関数は thunk ミドルウェアに処理される。 HTTP クライアントは SuperAgent を使ってみた。

var React = require("react");
var ReactDOM = require("react-dom");

var createStore = require("redux").createStore;
var applyMiddleware = require("redux").applyMiddleware; 
var combineReducers = require("redux").combineReducers;

var Provider = require("react-redux").Provider;
var connect = require("react-redux").connect;

var thunk = require("redux-thunk");

// 大人の事情で ES2015 が使えないので、
// map や Object.assign を lodash で代用
var _ = require("lodash");

// HTTP クライアントは SuperAgent を使う
var request = require("superagent");

// thunk ミドルウェアを使う
var createStoreWithMiddleware = applyMiddleware(thunk)(createStore);

// アクションを定義
var START_FETCH_PLAYERS = "START_FETCH_PLAYERS";
var SUCCESS_FETCH_PLAYERS = "SUCCESS_FETCH_PLAYERS";
var ERROR_FETCH_PLAYERS = "ERROR_FETCH_PLAYERS";

// プレーヤー一覧の取得開始を表すアクション
function startFetchPlayers() {
  return {
    type: START_FETCH_PLAYERS
  };
}

// プレーヤー一覧の取得成功を表すアクション
function successFetchPlayers(result) {
  return {
    type: SUCCESS_FETCH_PLAYERS,
    players: result
  };
}

// プレーヤー一覧の取得失敗を表すアクション
function errorFetchPlayers() {
  return {
    type: ERROR_FETCH_PLAYERS
  };
}

// Web API を呼び出す非同期アクション
function fetchPlayersAsync() {
  return function(dispatch) {
    dispatch(startFetchPlayers());

    request.get("/api/players")
      .end(function(err, res) {
        if (err) {
          dispatch(errorFetchPlayers());
        } else {
          dispatch(successFetchPlayers(res.body));
        }
      });
  };
}

// プレーヤーの状態を操作する Reducer
function playersReducer(state, action) {
  switch (action.type) {
    case SUCCESS_FETCH_PLAYERS:
      return action.players;
    case ERROR_FETCH_PLAYERS:
      return [];
    default:
      return state || [];
  }
}

// 進捗の状態を操作する Reducer
function progressReducer(state, action) {
  state = state || false;
  switch (action.type) {
    case START_FETCH_PLAYERS:
      return true;
    case SUCCESS_FETCH_PLAYERS:
      return false;
    case ERROR_FETCH_PLAYERS:
      return false;
    default:
      return state;
  }
}

// プレーヤーの行を表示するコンポーネント
var Player = React.createClass({
  render: function() {
    return (
      <tr>
        <td>{this.props.player.number}</td>
        <td>{this.props.player.name}</td>
      </tr>
    );
  }
});

// プレーヤー一覧を表示するコンポーネント
var Players = React.createClass({
  players: function() {
    return _.map(this.props.players, function(p) {
      return <Player player={p} />
    });
  },

  render: function() {
    return (
      <table>
        <thead>
          <tr>
            <th>Number</th>
            <th>Name</th>
          </tr>
        </thead>
        <tbody>
          {this.players()}
        </tbody>
      </table>
    );
  }
});

// 進捗を表示するコンポーネント
var Progress = React.createClass({
  render: function() {
    if (this.props.progress) {
      return (
        <div>取得中...</div>
      );
    } else {
      return (
        <div></div>
      );
    }
  }
});

var _MyApp = React.createClass({
  reload: function() {
    this.props.dispatch(fetchPlayersAsync());
  },

  render: function() {
    return (
      <div className="wrapper">
        <button onClick={this.reload}>Reload</button>
        <Progress progress={this.props.progress}/> 
        <Players players={this.props.players}/>
      </div>
    );
  }
});

// connect でラップし、ストアの状態を props に受け取れるようにする
var MyApp = connect(function(state) {
  return {
    players: state.players,
    progress: state.progress,
  };
})(_MyApp);

// ストアに渡す Reducer を作成
var rootReducer = combineReducers({
  players: playersReducer,
  progress: progressReducer
});

// ミドルウェアを組み込んだストアを作成
var store = createStoreWithMiddleware(rootReducer);

ReactDOM.render((
  <Provider store={store}>
    <MyApp />
  </Provider>
  ), document.getElementById("content")
);

Web API を呼び出して結果を表示するだけなのに、Redux のレール上でやろうとしたら、 思いのほかメンドかった。 この程度のサンプルだからそう感じただけで、 規模が大きいアプリになると恩恵を感じるようになるのかもな。 そうだといいな。