redux と react-router での非同期API呼び出しを含む universal app の作り方

redux, react-router を使った、サーバ側で非同期APIを叩き、内容を取得し終わるのを待ってからレンダリングを行うようなような universal app を作る方法(のひとつ)です。
内容は概ね[Server-Side Rendering with Redux and React-Router](https://www.codementor.io/reactjs/tutorial/redux-server-rendering-react-router-universal-web-app)と同じです。

- redux: v3.0.5
- react-router: v2.0.0-rc5
- redux-thunk: 1.0.3

で動作確認しています。

もっと良い方法がある気がしているのであれば教えて下さい。

# 2018/07/24 追記

nextjs の getInitialProps がこの記事における fetchData に近いことをするので、nextjs を使うのが簡単そうです。


# 方針

1. react-router の `match` で、要求されたURLに合致する React コンポーネントを得る
2. そのコンポーネントの静的メソッド (Promise を返す) を、react-router の `params` と redux の `dispatch` で呼び出す
3. `Promise.all` で待ち、終わったらレンダリング (この時点で store には必要な情報が入っている)

# 例

下のような react-router の `Route` で作られた `routes` を持つブログのようなSPAを考えます。`App` は全体に関わるようなコンポーネント、`Home` はホーム画面 (記事一覧が出る)、`Article` は `/articles/:id` のURLでアクセスされる一つ一つの記事の画面です。

```js
const routes = (
  <Route path="/" component={App}>
    <IndexRoute component={Home}/>
    <Route path="articles/:id" component={Article}/>
  </Route>
);
```

Home では記事一覧を得るようなAPIを叩き、Article ではパスパラメータの`:id`で記事の内容を取得するAPIを叩きます。取得した内容をサーバ側でレンダリングしてからクライアントに返します。

App, Home, Article の定義は以下のようになります。

```js:App.js
import React, { Component } from 'react';

class App extends Component {
  render() {
    return (
      <div>
        {this.props.children}
      </div>
    );
  }
}
```

```js:Home.js
import React, { Component } from 'react';
import { connect } from 'react-redux';

import { getArticles } from './actions';

class Home extends Component {

  static fetchData({ dispatch }) {
    return dispatch(getArticles());
  }

  componentWillMount() {
    if (/* 記事一覧がすでに取得されているかどうか */) {
      Home.fetchData({ dispatch: this.props.dispatch });
    }
  }

  render() {
    const { articles } = this.props;
    return (
      <div>
        <ul>
          {articles.map(article => (
             <li key={article.id}>
               <Link to=`articles/${article.id}`>{article.title}</Link>
             </li>
           ))}
        </ul>
      </div>
    );
  }
}

export default connect(
  state => ({
    articles: state.app.articles,
  })
)(Home);
```

```js:Article.js
import React, { Component } from 'react';
import { connect } from 'react-redux';
import _ from 'lodash';

import { getArticle } from '../actions';

class Article extends Component {

  static fetchData({ params, dispatch }) {
    return dispatch(getArticle(params.id));
  }

  componentWillMount() {
    const { params, dispatch, article } = this.props;
    if (_.isEmpty(article) || (params.id !== article.id)) { // 記事が取得済みかどうか
      Article.fetchData({ params, dispatch });
    }
  }

  render() {
    const { article } = this.props;
    return (
      <div>
        <h1>{article.title}</h1>
        <p>{article.body}</p>
      </div>
    );
  }
}

export default connect(
  state => ({
    article: state.app.article,
  })
)(Article);
```

redux-thunk を使うので Action は下のような感じになります (Ajax のライブラリは axios を使っています)。reducer は適当に。

```js:actions.js
export const getArticles = () => (dispatch) => {
  axios.get('https://api.example.com/articles').then(res => (
    dispatch({ type: SET_ARTICLES, articles: res.data })
  )); 
};

export const getArticle = (id) => (dispatch) => {
  axios.get(`https://api.example.com/articles/${id}`).then(res => (
    dispatch({ type: SET_ARTICLE, article: res.data });
};
```

次にサーバ側でもクライアント側でも使う `universal.js` の定義です。
`routes` と `store` を作る関数が含まれています。Redux の middleware に redux-thunk を使っています。

```js:universal.js
import React from 'react';
import { Router, Route, RouterContext, IndexRoute } from 'react-router';
import { createStore, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';

import App from './App.js';
import Home from './Home.js';
import Article from './Article.js';
import reducer from './reducers';

export const routes = (
  <Route path="/" component={App}>
    <IndexRoute component={Home}/>
    <Route path="articles/:id" component={Article}/>
  </Route>
);

export const configureStore = (initialState) => {
  return applyMiddleware(
    thunk
  )(createStore)(reducer, initialState);
};

const withReduxProvider = (store, children) => {
  return (
    <Provider store={store}>
      {children}
    </Provider>
  );
};

export const createClientApp = (store, history) => {
  return withReduxProvider(store, <Router history={history}>{routes}</Router>);
};

export const createServerApp = (store, props) => {
  return withReduxProvider(store, <RouterContext {...props}/>);
};
```

そしてサーバ側のコードです。react-router の `match` 関数を使って、`routes` から合致するコンポーネントのみを取得しています。これらの `fetchData` 関数を呼び出すことで、そのページに必要な情報をレンダリング前に取得します。
例えば、`/` がリクエストされると `[App, Home]` が、`/articles/1` がリクエストされると `[App, Articles]` が `match` の callback の `renderProps.components` の値になっているので、これらの `fetchData` 静的メソッド (この例では App は持っていないので `Promise.resolve` にしてしまう) を `store.dispatch` と `renderProps.params` で実行します。`/articles/1` にアクセスすると `renderProps.params` は `{ id: 1 }` になっているので、`fetchData` によってIDが1の記事を取得できます。
`fetchData` の返り値は Promise オブジェクトになっているので、`Promise.all` でこれらの終了をすべて待ってから、レンダリングを行います。レンダリングを行うときには store には記事一覧もしくは記事詳細が保存されています。その後全体のHTMLを作ってクライアント側に返すという感じです。

```js:server.js
import express from 'express';
import { match } from 'react-router';
import { renderToString } from 'react-dom/server';
import serialize from 'serialize-javascript';

import { createServerApp, routes, configureStore } from './universal';

const app = express();

const renderFullPage = (html, state) => {
  return `
    <!doctype html>
    <html>
      <head>
        <meta charset="utf-8">
        <title>server-side rendering with asynchronous API</title>
      </head>
      <body>
        <div id="app">${html}</div>
        <script>
          window.__INITIAL_STATE__ = ${serialize(state)};
        </script>
        <script src="/static/bundle.js"></script>
      </body>
    </html>
  `;
};

app.use((req, res) => {
  match({ routes, location: req.url }, (err, redirect, renderProps) => {
    if (err) {
      res.status(500).send(err.message);
    } else if (redirect) {
      res.redirect(302, redirect.pathname + redirect.search);
    } else if (!renderProps) {
      res.status(404).send('Not found');
    } else {
      const store = configureStore();
      const fetchingParams = { 
        params: renderProps.params, 
        dispatch: store.dispatch,
      };
      const promises = renderProps.components
        .map(c => c.fetchData ? c.fetchData(fetchingParams) : Promise.resolve('no fetching'));
      Promise.all(promises).then(() => {
        const app = createServerApp(store, renderProps);
        const html = renderToString(app);
        const initialState = store.getState();
        res.send(renderFullPage(html, initialState));
      });
    }
  });
});

app.listen(3000);
```

特になにもないですがクライアント側のコードです。

```js:client.js
import { render } from 'react-dom';
import { browserHistory } from 'react-router';

import { createClientApp, configureStore } from './universal';

const initialState = window.__INITIAL_STATE__;
const store = configureStore(initialState);
const app = createClientApp(store, browserHistory);

render(app, document.getElementById('app'));
```



# もっと複雑な例

https://github.com/satsukita-andon/andon-frontend はこの方法で作っています。

# 他の方法と参考になる議論

- [react-redux-universal-hot-example](https://github.com/erikras/react-redux-universal-hot-example)は[redux-router](https://github.com/acdlite/redux-router) (今は deprecated でよりシンプルな [react-router-redux](https://github.com/rackt/react-router-redux) が推奨されている)のサーバサイド用の機能を使っています(2016-01-26時点)。
- [Best async serverside loading technique? · Issue #99 · rackt/redux](https://github.com/rackt/redux/issues/99)
- [Support asynchronous server rendering (waiting for data before rendering) · Issue #1739 · facebook/react](https://github.com/facebook/react/issues/1739)