React + Expressでのサーバーサイドレンダリング方法のまとめ

  • 256
    いいね
  • 1
    コメント

Reactのサーバーサイドレンダリング(SSR)の実装方法について、React単体のシンプルなものから、React Router, Reduxを組み合わせたものまでまとめます。
サーバーサイドはExpressを用います。

※以下のJavaScriptのコードについて、クライアントサイドについてのみJSX + ES6形式でコーディングしており、webpackでcompileして利用しています

React単体でSSR

React単体でSSRを実現する場合は、ReactDOMServer.renderToStringを使用します。

参考にしたソース

React単体のSSRを実装するにあたっては以下のソースを参考にしました。

  • react-server-example
    • APP_PROPSを利用してfetchしたデータをクライアント側のjsと共有する点を参考にしました

静的な情報をレンダリングするパターン

このパターンのみでサービスが完結することはないと思いますが、SSRの第一歩目として見ていきます。

まずは、シンプルなReactComponentを用意します。

import React, { Component } from 'react';

export default class App extends Component {
  render() {
    return (
      <div>
        Hello SSR!
      </div>
    );
  }
}

curlなどでアクセスした際に"Hello SSR!"が表示されればSSRに成功したことになります。

Expressサーバー側のソースは以下です。

var express = require('express');
var app = express();
var path = require('path');
var React = require('../public/assets/app.server');

app.use(express.static(path.join(__dirname, '..', 'public')));

// for server side logic
app.get('api/items', function (req, res, next) {
  res.json([
    {id: 1, text: 'first'},
    {id: 2, text: 'second'},
    {id: 3, text: 'third'}
  ]);
});

// for server side rendering
app.get('*', function (req, res, next) {
  React(req, res);
});

app.listen(3000, function () {
  console.log('Example app listening on port 3000!');
});

データを返すapiのルートを1つ定義しつつ、それ以外の場合はReact側に処理を委譲します(最初はこの"/api/items"は使いません)。委譲先の/assets/app.serverというモジュールはSSR用のserver.jsxをcompileしたもので、以下のように実装しています。

import React from 'react';
import { renderToString } from 'react-dom/server';
import App from 'App';

function renderFullPage(renderedContent) {
  return `
  <!DOCTYPE html>
    <html>

    <head>
        <meta charset="utf-8">
        <title>React Server Rendering sample</title>
    </head>

    <div id="app">${renderedContent}</div>

    <script type="text/javascript" charset="utf-8" src="/assets/app.js"></script>
    </body>
    </html>

  `;
}

export default function render(req, res) {
  const renderedContent = renderToString(<App />);
  const renderedPage = renderFullPage(renderedContent);
  res.status(200).send(renderedPage);
};

ここでReactDOMServer.renderToStringを利用してサーバーサイドでdomツリーを生成して、htmlに埋め込んで返します。

また、クライアントで読み込むclient.jsx(/assets/app.jsとしてcompileされます)を用意します。

import React from 'react';
import ReactDOM from 'react-dom';
import App from 'App';

ReactDOM.render(<App />, document.getElementById('app'))

ここまで用意してサーバーを起動してcurlコマンドを叩いてみると、

<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8">
    <title>React Server Rendering sample</title>
</head>

<div id="app"><div data-reactid=".1c4y3s5msqo" data-react-checksum="-1658580955">Hello SSR!</div></div>

<script type="text/javascript" charset="utf-8" src="/assets/app.js"></script>
</body>
</html>

ちゃんと"Hello SSR!"が表示されることが確認できます。

非同期で取得したデータを用いてレンダリングするパターン

続いて、表示するデータをサーバーサイドでfetchする場合について記載します。このパターンで重要なのが、fetchした結果をブラウザ側にも共有することです。そうしないとブラウザ側でjsがloadされた際に再度fetchを走らせる必要が出てきてしまいます。

server.jsxのrender関数を

export default function render(req, res) {
  fetch('http://localhost:3000/api/items')
    .then(apiResult => apiResult.json())
    .then(items => {
      const initialProps = { items };
      const renderedContent = renderToString(<App {...initialProps} />);
      const renderedPage = renderFullPage(renderedContent, initialProps);
      res.status(200).send(renderedPage);
    }).catch(error => {
      res.status(500).send(error.message);
    });
};

として、データをfetchをした上でresponseを返すように変更します。また、同じくserver.jsxのrenderFullPage関数を修正して、サーバーサイドでfetchした結果をAPP_PROPSとして一旦scriptタグ内でglobal変数に格納します。

function renderFullPage(renderedContent, initialProps) {
  const appProps = safeStringify(initialProps);
  return `
  <!DOCTYPE html>
    <html>

    <head>
        <meta charset="utf-8">
        <title>React Server Rendering sample</title>
    </head>

    <div id="app">${renderedContent}</div>

    <script>
      var APP_PROPS = ${appProps};
    </script>
    <script type="text/javascript" charset="utf-8" src="/assets/app.js"></script>
    </body>
    </html>

  `;
}

なぜAPP_PROPSにデータを格納しているかというと、client.jsxで利用するためです。

import React from 'react';
import ReactDOM from 'react-dom';
import App from 'App';

const props = window.APP_PROPS;
ReactDOM.render(<App { ...props } />, document.getElementById('app'))

このようにAPP_PROPSを介して、サーバーサイドでfetchした結果をブラウザ側に共有します。

また、せっかくなのでApp.jsxでfetchしたpropsを表示するように修正します。

import React, { Component, PropTypes } from 'react';

export default class App extends Component {
  render() {
    const { items = [] } = this.props;
    return (
      <div>
        <h1>Hello SSR!</h1>
        <ul>
          {items.map(item => {
            return (<li key={item.id}>{item.id}: {item.text}</li>);
          })}
        </ul>
      </div>
    );
  }
}

App.propTypes = {
  items: PropTypes.array
};

curlを叩くと以下のhtmlが返ってきます。

<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8">
    <title>React Server Rendering sample</title>
</head>

<div id="app">
    <div data-reactid=".12o64evkrnk" data-react-checksum="401071875">
        <h1 data-reactid=".12o64evkrnk.0">Hello SSR!</h1>
        <ul data-reactid=".12o64evkrnk.1">
            <li data-reactid=".12o64evkrnk.1.$1"><span data-reactid=".12o64evkrnk.1.$1.0">1</span><span data-reactid=".12o64evkrnk.1.$1.1">: </span><span data-reactid=".12o64evkrnk.1.$1.2">first</span></li>
            <li data-reactid=".12o64evkrnk.1.$2"><span data-reactid=".12o64evkrnk.1.$2.0">2</span><span data-reactid=".12o64evkrnk.1.$2.1">: </span><span data-reactid=".12o64evkrnk.1.$2.2">second</span></li>
            <li data-reactid=".12o64evkrnk.1.$3"><span data-reactid=".12o64evkrnk.1.$3.0">3</span><span data-reactid=".12o64evkrnk.1.$3.1">: </span><span data-reactid=".12o64evkrnk.1.$3.2">third</span></li>
        </ul>
    </div>
</div>

<script>
  var APP_PROPS = {"items":[{"id":1,"text":"first"},{"id":2,"text":"second"},{"id":3,"text":"third"}]};
</script>
<script type="text/javascript" charset="utf-8" src="/assets/app.js"></script>
</body>
</html>

fetchしたitemsのデータがリスト形式で表示されています。また、scriptタグでfetchしたデータをAPP_PROPSに格納していることも確認できます。

React + React RouterでSSR

続いてReact Routerを利用した場合についてまとめます。アクセスされたurlに対するReact Componentの割り出しをサーバーサイドで実施する必要がありますが、React Routerでサポートされているので特に問題はありません。

参考にしたソース

静的な情報をレンダリングするパターン

まずはデータをfetchせず、単純にurlに対応するReact Componentを表示します。

複数のrouteを用意するために、App.jsxに加えてItems.jsx, Users.jsxというComponentを追加します。以下はItems.jsxのソースです(User.jsxもほぼ同様のコード)。

import React, { Component, PropTypes } from 'react';
import { Link } from 'react-router';

export default class Items extends Component {
  render() {
    const { items = [] } = this.props;
    return (
      <div>
        <h2>item list</h2>
        <Link to='/users'>usersへ</Link>
        <ul>
          {items.map(item => {
            return (<li key={item.id}>{item.id}: {item.name}</li>);
          })}
        </ul>
      </div>
    );
  }
}

Items.propTypes = {
  items: PropTypes.array
};

また、App.jsxをItems.jsxとUsers.jsxの親Componentとするために以下のように修正します。

import React, { Component, PropTypes } from 'react';

export default class App extends Component {
  render() {
    return (
      <div>
        <h1>Hello SSR!</h1>
        {this.props.children}
      </div>
    );
  }
}

App.propTypes = {
  children: PropTypes.object
};

そして、React Routerを使ったroutes.jsxを作成します。

import React from 'react';
import { Route } from 'react-router';

import App from './App';
import Items from './Items';
import Users from './Users';

export default (
  <Route path="/" component={App}>
    <Route path="items" component={Items} />
    <Route path="users" component={Users} />
  </Route>
);

続いて、このroutes.jsxをserver.jsx, client.jsxのそれぞれで利用します。まずはserver.jsxのrender関数を以下のように修正します。

import { match, RouterContext } from 'react-router'
import routes from './routes'
...
export default function render(req, res) {
  match({ routes, location: req.url }, (error, redirectLocation, renderProps) => {
    if (error) {
      res.status(500).send(error.message)
    } else if (redirectLocation) {
      res.redirect(302, redirectLocation.pathname + redirectLocation.search)
    } else if (renderProps) {
      const renderedContent = renderToString(<RouterContext {...renderProps} />);
      const renderedPage = renderFullPage(renderedContent);
      res.status(200).send(renderedPage);
    } else {
      res.status(404).send('Not found')
    }
  })
};

React Routerrのmatch関数とRouterContextを利用することで、urlに対応したComponent割り出してdomツリーを生成することができます。

client.jsxは以下のように修正します。

import React from 'react';
import ReactDOM from 'react-dom';
import { Router, browserHistory } from 'react-router'
import routes from './routes'

ReactDOM.render(
  <Router history={browserHistory}>
    {routes}
  </Router>,
document.getElementById('app'))

サーバーを起動してcurlを叩いてテストすると以下のようになります。

  • "/"にアクセスすると"Hello SSR!"が表示
  • "/items"にアクセスすると"Hello SSR!"に加えて"item list"が表示(まだitemsデータはfetchしていないのでタイトルのみ)

また、Item.jsxに仕込んでおいた"usersへ"のLinkをクリックすると"/users"へ遷移します。

非同期で取得したデータを用いてレンダリングするパターン

参考ソースでは、基本的に各Componentにstaticなデータ取得関数を定義しておいて、match関数によって関係するComponentが割り出された際にデータ取得を実行する、という方法を取っているようです。ただ、Componentが階層化されている場合、データを取得するだけでなく取得したデータを適切なComponentに渡す工夫が必要になります。

例えばItems.jsxやUsers.jsxだけでなくApp.jsxも独自のデータ取得をする場合、サーバーサイドではApp.jsxとItems.jsx、もしくはApp.jsxとUsers.jsxのデータがセットで取得されます。そして、App.jsxのデータ取得関数の実行結果はApp.jsxに、Items.jsx/Users.jsxのデータはItems.jsx/Users.jsxに渡す必要があります。

この動作の実現するため、今回はRouterとApp.jsxの間にデータ管理用のComponentを差し込む方法をとります。

Componentの階層構造を以下のようにします。

※App.jsx/Items.jsx/Users.jsxなどのComponentを[App Components]と記載、*をつけたものが差し込むComponent

  • Router : React RouterのルートComponent
    • *AppContext : [App Components]のデータ取得関数の実行結果データを保持するComponent
      • RouterContext : routes.jsxでの配下に定義された[App Components]をrenderするComponent
        • *PropsContainer : [App Components]にAppContextが保持するデータを渡すComponent
          • [App Component]

もし、/itemsにアクセスすると、

  • Router
    • AppContext
      • RouteContext
        • PropContainer
          • App.jsx

となり、App.jsx以下は

  • App.jsx
    • PropContainer
      • Item.jsx

という階層構造になります。

ちなみにこの方法は、上で挙げたasync-propsを参考にしています(説明に必要な部分だけを切り出したので、同じことがやりたい場合は素直にこのモジュールを利用する方が良いです)。

まずApp.jsx/Items.jsx/Users.jsxにデータ取得関数を定義します。App.jsxは、

import React, { Component, PropTypes } from 'react';
import fetch from 'isomorphic-fetch';

export default class App extends Component {

  static loadProps(callback) {
    fetch('http://localhost:3000/api/me')
      .then(res => res.json())
      .then(json => callback(null, {me: json}))
      .catch(error => callback(error));
  }

  render() {
    const { me } = this.props;
    return (
      <div>
        <h1>Hello {me.name}!</h1>
        <p>server date {me.date}!</p>
        {this.props.children}
      </div>
    );
  }
}

App.propTypes = {
  children: PropTypes.object
};

というように、データ取得関数(loadProps)を追加し、propsのデータを表示するように修正しています。
loadPropsはfetchが完了したらcallbackを呼びますが、その際の引数は自身に渡して欲しいpropsの形式とします({me: json})。
Items.jsx/Users.jsxにも同様にloadPropsを定義し、propsを描画するように修正します。

続いてserver.jsxのrender関数で各ComponentのloadPropsを実行します。

export default function render(req, res) {
  match({ routes, location: req.url }, (error, redirectLocation, renderProps) => {
    if (error) {
      res.status(500).send(error.message)
    } else if (redirectLocation) {
      res.redirect(302, redirectLocation.pathname + redirectLocation.search)
    } else if (renderProps) {
      const componentsArray = renderProps.components.filter(component => component.loadProps);
      let propsArray = [];
      Promise.all(
        componentsArray.map((component, index) => {
          return new Promise((resolve, reject) => {
            component.loadProps((error, fetchResult) => {
              if (error) {
                reject(error);
              } else {
                propsArray[index] = fetchResult;
                resolve();
              }
            });
          });
        })
      ).then(() => {
        const propsAndComponents = { componentsArray, propsArray };
        const renderedContent = renderToString(<AppContext { ...renderProps } { ...propsAndComponents } />);
        const renderedPage = renderFullPage(renderedContent, propsArray);
        res.status(200).send(renderedPage);
      }).catch(error => {
        res.status(500).send(error.message)
      });
    } else {
      res.status(404).send('Not found')
    }
  });
};

React Routerのmatch関数で割り出されたComponentのうちloadPropsを実装しているものをfilteringした上で、それぞれのloadPropsを実行し、Promise.allで全ての処理が完了するのを待って、取得データをデータ管理用ComponentであるAppContextに渡しています。また、取得データをブラウザにも共有するためにrenderFullPage関数の第2引数として渡しています(このサーバーサイドで取得したデータをブラウザに共有する方法は既に"React単体でSSR"で載せているので省略)。

client.jsxでもRouter直下にAppContextを差し込むようにRouterのrenderプロパティーを上書きします。

import React from 'react';
import ReactDOM from 'react-dom';
import { Router, browserHistory } from 'react-router';
import routes from './routes';
import AppContext from './AppContext';

ReactDOM.render(
  <Router history={browserHistory} render={(props) => <AppContext {...props}/>}>
    {routes}
  </Router>,
document.getElementById('app'))

続いてAppContext/PropsContainerの実装です。

import React, { Component, PropTypes } from 'react';
import { RouterContext } from 'react-router/lib'

// APP_PROPSに格納したデータを取得してAppContextが扱える形にして返す
function hydrate(props) {
  if (typeof APP_PROPS !== 'undefined')
    return {
      propsArray: APP_PROPS,
      componentsArray: props.components.filter(component => component.loadProps)
    }
  else
    return null
}

// RouterContextのcreateElementプロパティにこの関数を指定して、PropsContainerを差し込む
function createElement(Component, props) {
  if (Component.loadProps)
    return <PropsContainer Component={Component} routerProps={props}/>
  else
    return <Component {...props}/>
}

export default class AppContext extends Component {

  static childContextTypes = {
    asyncProps: PropTypes.object
  };

  static propTypes = {
    components: PropTypes.array.isRequired,
    params: PropTypes.object.isRequired,
    location: PropTypes.object.isRequired,
    // server rendering
    propsArray: PropTypes.array,
    componentsArray: PropTypes.array
  };

  constructor(props, context) {
    super(props, context)
    const { propsArray, componentsArray } = this.props
    const isServerRender = propsArray && componentsArray
    // サーバーサイドだとpropsで渡されたものを利用し、ブラウザではhydrate関数でAPP_PROPSからデータを取得
    this.state = {
      propsAndComponents: isServerRender ?
        { propsArray, componentsArray } :
        hydrate(props)
    }
  }

  // 管理しているデータをContextを利用して子に渡す
  getChildContext() {
    const { propsAndComponents } = this.state
    return {
      asyncProps: { propsAndComponents }
    };
  }

  render() {
    // createElementプロパティを上書き
    return <RouterContext {...this.props} createElement={createElement}/>
  }
}

// アプリケーション側のComponentが必要とするデータを割り出す
function lookupPropsForComponent(Component, propsAndComponents) {
  const { componentsArray, propsArray } = propsAndComponents
  var index = componentsArray.indexOf(Component)
  return propsArray[index]
}

class PropsContainer extends React.Component {

  static propTypes = {
    Component: PropTypes.func.isRequired,
    routerProps: PropTypes.object.isRequired
  };

  static contextTypes = {
    asyncProps: PropTypes.object.isRequired
  };

  render() {
    const { Component, routerProps, ...props } = this.props
    const { propsAndComponents } = this.context.asyncProps
    const asyncProps = lookupPropsForComponent(Component, propsAndComponents)
    return (
      <Component
        {...props}
        {...routerProps}
        {...asyncProps} />
    )
  }

}

1つのファイルにAppContextとPropsContainerを定義しています。exportしているのはAppContextのみです。

AppContextはconstructorでデータ(loadPropsの実行結果)を渡され、それをstateにセットします。renderするのはRouterContextですが、createElementプロパティを指定していて、この関数の中でPropsContainerを差し込みます。またgetChildContextを定義しており、PropsContainerにデータを渡せるようにしています。また、サーバーサイドでロードしたデータはAPP_PROPSという変数に格納されており、クライアントサイドではそれをhydrate関数で取得します。

PropsContainerはAppContextが保持するデータからの中から、lookupPropsForComponent関数で生成するComponentに必要なものだけを抽出して渡す役割を担っています。

ここまで実装してcurlでテストすると、

  • "/"にアクセスすると、"Hello hoge!"と"server date 2016-01-27T13:35:16.891Z"が表示("hoge"と"2016-01..."がfetchした結果)
  • "/items"にアクセスすると上記に加えて、itemのリストが表示

となります。

ただ、この時点ではバグがあります。
Items.jsxとUsers.jsxに定義したLink要素をクリックして遷移すると、遷移先の画面のリストが表示されません。

これは定義したloadPropsがサーバーサイドでしか呼ばれていないために発生します。そこでそれぞれのComponentのcomponentWillMount関数内でloadPropsを呼ぶように修正します。

Items.jsxは以下のようになります。

import React, { Component, PropTypes } from 'react';
import { Link } from 'react-router';
import fetch from 'isomorphic-fetch';

export default class Items extends Component {

  static loadProps(callback) {
    fetch('http://localhost:3000/api/items')
      .then(res => res.json())
      .then(json => callback(null, {items: json}))
      .catch(error => callback(error));
  }

  constructor(props, context) {
    super(props, context);
    this.state = {
      items: props.items
    };
  }

  componentWillMount() {
    if (this.context.didMount) {
      Items.loadProps((ignore, res) => {
        this.setState({ items: res.items });
      });
    }
  }

  render() {
    const { items = [] } = this.state;
    return (
      <div>
        <h2>item list</h2>
        <Link to='/users'>usersへ</Link>
        <ul>
          {items.map(item => {
            return (<li key={item.id}>{item.id}: {item.text}</li>);
          })}
        </ul>
      </div>
    );
  }
}

Items.propTypes = {
  items: PropTypes.array
};

Items.contextTypes = {
  didMount: PropTypes.bool
};

Items.jsxやUsers.jsxにcomponentWillMountを定義するのに合わせて、幾つか修正をしています。

  • propsの情報を一旦stateに格納した上で、表示に利用

これは、componentWillMountでデータを読み込んだ際に、簡易的に表示を切り替えられるようにするための対応です。
修正前のままだと、state管理している親Component(今回のケースだと例えばAppContextなど)に一旦ロードしたデータを渡した上で、再度propsを受け取る必要が出てきてしまうためです。

  • AppContextでcomponentDidMountが呼ばれた際にdidMountというstateをtrueにセット
  • contextTypesでAppContextのdidMountを受け取る
  • didMountがtrueの場合のみcomponentWillMountでloadPropsを実行

これらは、サーバーサイド、もしくはサーバーサイドレンダリングが実施されたhtmlを読み込んだ直後に、loadPropsが実行されないようにするための対応です。このdidMountはAppContextのcomponentDidMountが実行されたタイミングでtrueになるように実装しているため、サーバーサイドではtrueにはなりません。またブラウザでjsがロードされた直後のタイミングでも同様にtrueではありません。そのため重複してデータのfetchが実行されることを防ぐことができます。

ここまでのまとめ

このように、React Routerとサーバーサイドレンダリングを組み合わせることはできますが、データの管理が煩雑になります。async-propsは上で説明した実装を隠蔽して、使う側があまり意識する必要がないようになっていますが、正直な感想としてはそこまでやるのだったら素直にFluxを導入した方が良さそうです。

React + React Router + Redux

最後にReduxを組み合わせた場合です。

参考にしたソース

非同期で取得したデータを用いてレンダリングするパターン

データをfetchして表示するパターンのみ説明します。
Reduxを使うとデータの管理はstoreに任せることができるので、自前のComponentが不要になります(上の例でのAppContext/PropsContainer)。初期表示用のデータを取得する処理に関しては、React + React Routerのパターンと同様に各Componentにstaticな関数として定義することになります。

App.jsxは以下のように変更します。

import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { loadMe, didMount } from './actions';

class App extends Component {

  // データ取得関数
  static fetchData() {
    return loadMe();
  }

  static propTypes = {
    children: PropTypes.object
  };

  componentWillMount() {
    const { dispatch } = this.props;
    if (this.props.didMount) {
      dispatch(loadMe());
    }
  }

  // 上でも利用しているthis.props.didMountをtrueとするためにアクションをdispatch
  componentDidMount() {
    const { dispatch } = this.props;
    dispatch(didMount());
  }

  render() {
    const { me } = this.props;
    return (
      <div>
        <h1>Hello {me.name}!</h1>
        <p>server date {me.date}!</p>
        {this.props.children}
      </div>
    );
  }
}

function mapStateToProps(state) {
  return {
    didMount: state.app.didMount,
    me: state.app.me
  };
}

export default connect(
  mapStateToProps
)(App);

これまでの例と異なる点は、componentDidMountでDID_MOUNTアクションをdispatchしている点と、reduxのstoreにconnectしていることです。reducerではDID_MOUNTアクションによって、state.app.didMountフラグをtrueに更新します。

import { DID_MOUNT, LOADED_ME } from 'actions';

export default function app(state = {
  didMount: false,
  me: {}
}, action) {
  switch (action.type) {
    case DID_MOUNT:
      return Object.assign({}, state,
        { didMount: true }
      );
    case LOADED_ME:
      const { me } = action;
      return Object.assign({}, state,
        { me }
      );
    default:
      return state;
  }
}

このdidMountフラグによって、1つ前のパターンで説明したのと同様に各ComponentにおいてcomponentWillMountでデータをロードするかを制御するようにします。

App.jsxの説明に戻りますが、データ取得のためのstaticな関数であるfetchDataでは、別のファイルに定義されたloadMeというaction creatorを呼び出して返しています。loadMe関数は以下のような内容です。

export function loadMe() {
  return dispatch => {
    // サンプルのため決め打ちのurl
    return fetch('http://localhost:3000/api/me')
      .then(res => {
        if (res.status >= 400) {
            throw new Error('Bad response from server');
        }
        return res.json();
      }).then(data => {
        return dispatch({
          type: LOADED_ME,
          me: data
        });
      });
  };
}

fetchしたデータをactionに詰めてdispatchします。このactionはreducerで補足されデータはstoreに格納されます。

Items.jsx/Users.jsxについては、componentDidMountは実装を除いたほぼ同じようなコードとなります。

続いてserver.jsxのrender関数を修正します。

import { Provider } from 'react-redux';
import configureStore from './configureStore';
...
export default function render(req, res) {
  match({ routes, location: req.url }, (error, redirectLocation, renderProps) => {
    if (error) {
      res.status(500).send(error.message)
    } else if (redirectLocation) {
      res.redirect(302, redirectLocation.pathname + redirectLocation.search)
    } else if (renderProps) {
      const store = configureStore({});
      const components = renderProps.components.filter(component => component.fetchData);
      Promise.all(components.map(component => store.dispatch(component.fetchData())))
        .then(() => {
          const renderedContent = renderToString(
            <Provider store={store}>
              <RouterContext {...renderProps} />
            </Provider>
          );
          const renderedPage = renderFullPage(renderedContent, store.getState());
          res.status(200).send(renderedPage);
        }).catch(error => {
          res.status(500).send(error.message);
        });
    } else {
      res.status(404).send('Not found')
    }
  })
};

Reduxのstoreを生成した上でComponentに定義されたfetchDataをdispatchして、すべてのfetchが完了した時点でReduxのProvider Componentを生成しています。dispatchはstoreに定義された関数で、このようにfetchDataを呼びことでその結果がstoreに格納されることになります。また、fetchしたデータをstore.getState()で取得することができるのでrenderFullPageに渡しています。renderFullPageではこのデータをAPP_STATE変数に格納します。

client.jsxも修正します。

import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { Router, browserHistory } from 'react-router'
import configureStore from './configureStore';
import routes from './routes'

const store = configureStore(window.APP_STATE);

ReactDOM.render(
  <Provider store={store}>
    <Router history={browserHistory}>
      {routes}
    </Router>
  </Provider>,
document.getElementById('app'))

APP_STATE変数に格納されたデータをブラウザ側でのstore初期化に利用します。

他にもitems, usersのためのactionやreducerの定義が必要ですが省略します。curlとブラウザでテストすると、初期表示とLink要素による画面遷移が正しく動作することが確認できます。

まとめ

説明に用いたサンプルコードはgithubに上げてあります。

Reactの勉強がてらにまとめてみましたが、自分としてもしっくりきていない部分もあるので(特にcomponentWillMountでのdidMountによる制御)、他に良いやり方がありましたら、教えてもらえるとありがたいです。

Reactを触り始めた当初はnodeサーバー使えばサクッとサーバーサイドレンダリングできるものとばかり思っていたんですが、サーバーサイドでどこまでデータを用意するかという点をしっかり考え出すと、結構難しい問題になります。

参考にしたソースではstaticに定義したデータ取得関数をサーバー/クライアントサイドで使い回す前提となっているものが多かったのですが、ある程度の複雑さがあるサービスではあまり現実的ではない気もします。