Skip to content
This repository has been archived by the owner on Nov 24, 2022. It is now read-only.

Tutorial 4 (Universal)

Nick Dreckshage edited this page Jan 23, 2016 · 2 revisions

This tutorial builds off the [previous](Tutorial 3 (Customize)), to turn this into a universal app that render on client & server.

Server setup

First let's temporarily disable webpack / client build.

In server.js

// app.use(webpackDevMiddleware(webpack(WebpackConfig), { publicPath: '/__build__/', stats: { colors: true }}));

And see if we can get the server to detect our routes.

In server.js:

//...
import React from 'react';
import { match } from 'react-router';
import routes from './routes';

// ...

match({ routes, location: req.url }, (
  routingErr,
  routingRedirectLocation,
  renderProps
) => {
  if (renderProps) {
    console.log(renderProps);
  }
});

res.send(html);
// ...

Normally you'd want to handle error cases, as shown here.

Reusing our store / serializer on the server

GroundControl exports loadStateOnServer as top level API. In order to use it, we need to pass it a store, just like we do on the client.

First, create our new files:

touch store.js serializer.js

In store.js:

import { createStore } from 'redux';

export default (initialState = {}) => {
  const store = createStore((state = initialState) => state);
  // NOTE! you'll see this log a fair amount. On route change, we create a new reducer & replace the one the store uses.
  // this triggers a few internal actions, like @@redux/INIT, @@anr/REHYDRATE_REDUCERS. it's expected.
  const logCurrentState = () => console.log(JSON.stringify(store.getState()));
  store.subscribe(logCurrentState);
  logCurrentState();

  return store;
};

In serializer.js:

export default (route, data) => {
  if (route.immutable) return data.toJS();
  return data;
};

Update client.js:

// ...
import routes from './routes';
import createStore from './store';
import serializer from './serializer';

const store = createStore();
render((
  <Router routes={routes} history={browserHistory} render={(props) => (
    <GroundControl {...props} store={store} serializer={serializer} />
  )} />
), document.getElementById('app'));

Load state on server

Now let's import our store and load state on the server.

In server.js:

// ...
import { loadStateOnServer } from 'ground-control';
import createStore from './store';

// ...

match({ routes, location: req.url }, (
  routingErr,
  routingRedirectLocation,
  renderProps
) => {
  if (renderProps) {
    const store = createStore();
    loadStateOnServer({ props: renderProps, store }, (
      loadDataErr,
      loadDataRedirectLocation,
      initialData,
      scriptString
    ) => {
      console.log(initialData);
    });
  }
});
// ...

With any luck you'll see our state log out when you try and refresh the page!

Pass data & renderToString

In server.js:

import { renderToString } from 'react-dom/server';
import GroundControl, { loadStateOnServer } from 'ground-control';
import createStore from './store';

// ...

const html = (renderProps, store, initialData, scriptString) => {
  const appString = renderToString(
    <GroundControl {...renderProps} store={store} initialData={initialData} />
  );

  return `
    <!DOCTYPE html>
    <html>
      <head></head>
      <body>
        <div id="app">${appString}</div>
        <script src="/__build__/bundle.js" async></script>
        ${scriptString}
      </body>
    </html>
  `;
};

// ...

const render = (req, res) => {
  match({ routes, location: req.url }, (
    routingErr,
    routingRedirectLocation,
    renderProps
  ) => {
    if (renderProps) {
      const store = createStore();
      loadStateOnServer({ props: renderProps, store }, (
        loadDataErr,
        loadDataRedirectLocation,
        initialData,
        scriptString
      ) => {
        if (loadDataErr) {
          res.status(500).send(loadDataErr.message);
        } else if (loadDataRedirectLocation) {
          res.redirect(302, `${loadDataRedirectLocation.pathname}${loadDataRedirectLocation.search}`);
        } else {
          res.status(200).send(html(renderProps, store, initialData, scriptString));
        }
      });
    }
  });
};

Now reload! You get your app! We aren't serving JavaScript yet - navigate around. Even go to the redirect route we set up in our previous tutorial - 302 redirect server side.

Server render, client render

Comment back in webpack.

In server.js:

app.use(webpackDevMiddleware(webpack(WebpackConfig), { publicPath: '/__build__/', stats: { colors: true }}));

Great, JS works! But there's an ugly React warning saying that we couldn't reuse the DOM.

Client hydration

Notice the ${scriptString} in HTML we are sending. In Chrome, type __INITIAL_DATA__ and you'll see the data we need to hydrate the app. You'll see some route info in there as well, because we want to tell the client which routes have completed loading server side, and which need more data (more on that in the next section...).

Lets adjust our imports and then wrap render in a callback to feed in our data.

In client.js:

import GroundControl, { loadStateOnClient } from 'ground-control';
// ...

loadStateOnClient({ routes }, initialData => {
  const store = createStore(initialData.initialState);
  render((
    <Router routes={routes} history={browserHistory} render={(props) => (
      <GroundControl {...props} store={store} serializer={serializer} initialData={initialData} />
    )} />
  ), document.getElementById('app'));
});

Immutable

One quick problem. We used immutable data! We added serializers for making accessing POJO's in components. But __INITIAL_DATA__ is simple JSON and we need to deserialize it for our reducers.

In components/AppComponent.js:

// ...
export const serializer = data => {
  return {
    ...data,
    currentUser: data.currentUser.toJS()
  };
};

export const deserializer = data => {
  return {
    ...data,
    currentUser: fromJS(data.currentUser)
  };
};
// ...

In routes.js:

// ...
import { component as AppComponent, reducer as appReducer, serializer as appSerializer, deserializer as appDeserializer } from './components/AppComponent';
// ...
<Route path="/" component={AppComponent} reducer={appReducer} serializer={appSerializer} deserializer={appDeserializer}>
// ...

And as before, if you are using immutable a lot. Do it at the app level.

touch deserializer.js

In deserializer.js:

import { fromJS } from 'immutable';
export default (route, data) => {
  if (route.immutable) return fromJS(data);
  return data;
};

In client.js:

// ...
import deserializer from './deserializer';
// ...
loadStateOnClient({ routes, deserializer }, initialData => {
// ...

In server.js:

// ...
import serializer from './serializer';
// ...
<GroundControl {...renderProps} store={store} initialData={initialData} serializer={serializer} />
// ...

Universal fetchData API

The universal fetchData API is pretty powerful. It lets you manage how you fetch data on the client and server. For example, you can fetch top of page data on the server, and then finish the page on the client. If it is entirely rendered client side, fetch the whole thing. It also handles errors, redirects, and when to transition from a blocking fetch to a non-blocking fetch with a 'preview template'. Let's make one of our fetchData calls a bit more complex.

In components/AsyncComponent.js:

export const fetchData = (done, {
  dispatch, clientRender, serverRender,
  isInitialLoad, err, redirect, isClient
}) => {
  const forceErr = Math.random() < .25;
  const forceRedirect = !forceErr && Math.random() < .25;

  clientRender();
  if (!isInitialLoad()) {
    setTimeout(() => {
      dispatch(asyncActions.load(['a', 'b', 'c', 'd', 'e']));
      if (forceErr) err({ message: 'Oops!' });
      if (forceRedirect) redirect({ pathname: '/' });
      serverRender();
    }, 1000);
  }

  if (isClient()) {
    setTimeout(() => {
      dispatch(asyncActions.load(['f', 'g', 'h']));
      done();
    }, 1000);
  }
};

Done!

That was quite a bit but hopefully straightforward!

You...

  • got the app rendering server side.
  • hydrated the client, and reused markup on client.
  • handled serialization / deserialization of data on client & server.
  • used universal fetchData API to optimize loading.