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
で動作確認しています。
もっと良い方法がある気がしているのであれば教えて下さい。
2018/07/24 追記
nextjs の getInitialProps がこの記事における fetchData に近いことをするので、nextjs を使うのが簡単そうです。
方針
- react-router の
match
で、要求されたURLに合致する React コンポーネントを得る - そのコンポーネントの静的メソッド (Promise を返す) を、react-router の
params
と redux のdispatch
で呼び出す -
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 の定義は以下のようになります。
import React, { Component } from 'react';
class App extends Component {
render() {
return (
<div>
{this.props.children}
</div>
);
}
}
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);
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 は適当に。
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 を使っています。
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を作ってクライアント側に返すという感じです。
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);
特になにもないですがクライアント側のコードです。
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はredux-router (今は deprecated でよりシンプルな react-router-redux が推奨されている)のサーバサイド用の機能を使っています(2016-01-26時点)。
- Best async serverside loading technique? · Issue #99 · rackt/redux
- Support asynchronous server rendering (waiting for data before rendering) · Issue #1739 · facebook/react