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

  • 51
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

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

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

で動作確認しています。

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

方針

  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でアクセスされる一つ一つの記事の画面です。

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

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

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

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

class App extends Component {
  render() {
    return (
      <div>
        {this.props.children}
      </div>
    );
  }
}
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);
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 は適当に。

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 の定義です。
routesstore を作る関数が含まれています。Redux の middleware に redux-thunk を使っています。

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.dispatchrenderProps.params で実行します。/articles/1 にアクセスすると renderProps.params{ id: 1 } になっているので、fetchData によってIDが1の記事を取得できます。
fetchData の返り値は Promise オブジェクトになっているので、Promise.all でこれらの終了をすべて待ってから、レンダリングを行います。レンダリングを行うときには store には記事一覧もしくは記事詳細が保存されています。その後全体のHTMLを作ってクライアント側に返すという感じです。

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);

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

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 はこの方法で作っています。

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