Extremely detailed guide to how react starter kit is wired and works, from beginning to end

Max Mitschke edited this page Mar 25, 2017 · 4 revisions

Note: in order to keep this as detailed and relevant at the same time as possible, the guide currently only covers everything from running a tool like npm run start to the end of server-side stuff. Still missing is a detailed explanation of what happens only on the client side.

The react-starter-kit in depth

The react-starter-kit, or rsk, is a yet another boilerplate. It is intended to provide an example of a complete React-based, universal application with multiple persistence stores, GraphQL, and a modular design. It happens to be one of the more popular and better-designed boilerplates available, but it is also a bit more complicated than the average boilerplate, mainly because of its complexity.

This guide explains in detail why the rsk is constructed as it is, an explanation that is sorely lacking from the current project. That includes, crucially, some detail about the persistence mechanisms it uses and the tools folder that backs all of the npm run commands.

babel-node tools/run ???

Project authors have recently opted to drop other task-running systems, including previous heavyweights like grunt and gulp, in favor of an npm-only approach. rsk follows this trend, and all of its tasks are executed by running an npm script defined in the scripts node of the root package.json.

These scripts can be divided into two types: those that are executed by running the task running script in tools/run, and those that are not. The interesting scripts - like start and build and deploy - all start with that tools/run script, so it is worth examining.

tools/run.js

The tools/run script is very short and simple: it defines two functions, runs a conditional, and exports one function.

function format(time) {
  return time.toTimeString().replace(/.*(\d{2}:\d{2}:\d{2}).*/, '$1');
}

function run(fn, options) {
  const task = typeof fn.default === 'undefined' ? fn : fn.default;
  const start = new Date();
  console.log(
    `[${format(start)}] Starting '${task.name}${options ? `(${options})` : ''}'...`
  );
  return task(options).then(resolution => {
    const end = new Date();
    const time = end.getTime() - start.getTime();
    console.log(
      `[${format(end)}] Finished '${task.name}${options ? `(${options})` : ''}' after ${time} ms`
    );
    return resolution;
  });
}

if (require.main === module && process.argv.length > 2) {

  delete require.cache[__filename]; // eslint-disable-line no-underscore-dangle

  const module = require(`./${process.argv[2]}.js`).default;
  run(module).catch(err => { console.error(err.stack); process.exit(1); });
}

export default run;

The run function is explained in the next section, and the other function format(time) is not important.

The other part of the script, the conditional inside of the if block, runs whenever both of the following are true:

  1. The module is imported as a module rather than called as a command. That is, require.main === module if the module is running as an imported module and not as a command.
  2. At least one argument is provided to the original process. Recall that process.argv always includes at least two arguments, ['node', 'myScript.js'], so the first "real" argument exists if process.argv.length > 2

That second condition, by the way, doesn't seem to make a lot of sense at first glance. Why would the author check that this is not being called as a command and then parse out command arguments?

The answer is within the conditional:

  delete require.cache[__filename]; // eslint-disable-line no-underscore-dangle

  const module = require(`./${process.argv[2]}.js`).default;
  run(module).catch(err => { console.error(err.stack); process.exit(1); });    

The first line is a call to delete the require cache for the current module's filename. Whenever a module is required, it typically stays cached for the duration of the request or command or run. However, using delete require.cache, a developer can force the module to reload.

That would be important - that is, run.js needs to be reloaded - if a developer plans on calling run more than once. The delete removes the responsibility of clearing the cache from the developer.

The second line sets the module to run by reading the provided argument in process.argsv[2], which was determined to be set previously from the conditional. Again this begs a question: why is there an argument being provided to a require call in the first place, especially when it is a given that it is being called as a module and not a command?

In other words, if this is being used as a module, isn't it probably used as follows?

const run = require('./run')
run(something)
run(somethingElse)

The answer is yes, of course. But this is not the only way it can be used as a module. Here is another very important way in which it can be used as a module while being specified from the shell or command line.

$ babel-node tools/run start

Note that node is the executable, tools/run is the file being executed, and so, start is actually the first argument (or the third, if you count the node way). babel-node is just node that can run babel on the fly and transpile code as necessary.

This can be confirmed by logging the process.argv array anywhere in the run script:

console.log(`Printing the args: ${process.argv}`)

The result is as follows:

Printing the args: node,/path/to/project/tools/run,start
                    1                2               3

Finally, the third line of the conditional does the interesting part - it calls run on the requested module loaded in the previous step, and handles any errors with a very simple error handler.

function run(fn, options)

The run function can be used directly if called as a command or from a module that is not providing an argument to run.

In either case, it accomplishes the same ends:

  1. Given (fn, options), it sets a const task whose value is either fn or fn.default if fn.default is defined.
  2. It creates a start Date() and logs it to the console
  3. It runs the const task with the provided options if they exist and then runs a slightly-modified completion block that logs the end time and the duration

The only thing worth noting here is that fn is not a particularly good choice of name for the argument that is actually needed by the run method. In fact, promise would be a much better name, because it is a Promise - or an async function - that is required as an input to run, as evidenced by the way in which the completion block is set up. It assumes the then method is available on task, which in turn assumes that task or fn or fn.default has the method, which is only true when fn or the default is a Promise.

npm run start

With a clear understanding of what npm run X is doing, the next obvious step is to examine what npm run start in particular does - and how it does it. Because npm run start kicks off or bootstraps the entire application on both client and server, it is fair to claim that understanding this script is tantamount to understanding how the whole project works.

As evidenced by the previous section, npm run start kicks off tools/run.js start, which then executes run(start), which simply runs the async function or Promise named start.default in tools/start.js.

The async function start() itself consists of three strictly sequential steps:

  1. Cleaning the target directory, accomplished in its first line, await run(clean)
  2. Copying the necessary files over to the target directory, accomplished in its second line, await run(copy.bind(undefined, { watch: true }))
  3. Everything else, all webpack-related, in its other hundred or so lines

The first two steps make use of tools/run.js to call two other tools that are worth a look but not as important to understand, because they do something very obvious: cleaning and copying files.

The third step is a bit more eclectic but its high points can be summarized as follows:

  1. Patch the client webpack configuration to enable HMR and React Transform for babel
  2. Run webpack(webpackConfig) where webpackConfig is derived after the previous step, and assign it to bundler
  3. Declare/assign const wpMiddleware = webpackMiddleware(bundler, { ... some config ... })
  4. Also use the bundler and webpackHotMiddleware to derive hotMiddlewares
  5. Define a handler handleServerBundleComplete to kick off the server with runServer as soon as the bundler is finished bundling.
  6. Set the bundler's handler for the 'done' event to the handler in the previous step with bundler.plugin('done', () => handleServerBundleComplete());

It should be said, by the way, that this portion of the guide makes a lot more sense after a few casual but repeated glances at the actual tools/start.js code.

If the steps above seem a bit random or out-of-place, or their motivation remains unclear, the following section explains what is happening and cruically why it is necessary.

What needs to happen in npm run start

From the developer's perspective, npm run start has a single and loose kind of purpose: to "start" the application in some meaningful fashion, so that the developer can test it or otherwise use it locally.

From the other side of the coin, though, npm run start is a tougher cookie to crack. To define such a task requires understanding what starting actually entails, which is more complicated for universal react applications than one may be inclined to imagine.

Recall that a universal application is one that can run, at least in part, on both client and server, and from the same code base. Here is one way it can be accomplished in react, and the way endorsed by the authors of rsk:

  1. Define a set of routes that should be addressable on the client or server sides. In rsk, these routes are found at src/routes.
  2. Define an express-based server application and set up express to handle this set of routes, ideally using a universal-style router like the universal-router used in rsk.
  3. For each server route, render both the Html container page and the inner App part of the page (very likely actually the entire page), at the end of each successful request
  4. When the client finally loads, the App markup will be rendered already, but any componentDidMount and subsequent calls will be handled by the client itself, just like any calls to other routes will.

The file src/server.js is responsible for doing steps three and four above; that is, for boostrapping the server, defining handlers for shared routes, and including a render method at the end of the handlers that mirrors the client render method.

So, one thing is or should be certain by now: npm run start needs to finish with an import and call to start bootstrapping the src/server.

Where webpack fits into the picture of calling src/server

With the understanding that "bootstrapping the test application" and "starting the server" and "running src/server.js" are synonymous, it is confusing that nearly all of the tools/start.js file concerns webpack. A better way of putting it may be: what does webpack have to do with the server, and why must one wait for webpack to finish before launching the server? Isn't webpack more of a client-side concern?

For non-universal applications, this would be a very good question indeed. While webpack is a great tool that has saved developers untold miliions of hours, it is far less important to bundle a server application prior to testing it locally. And furthermore, that could be accomplished without such a heavy-duty tool.

But what makes universal applications different, and what makes webpack a prerequisite operation before starting src/server, is that the server renders the client! Without a working client code base, the server would not be able to complete a request, so it is sensible to wait for webpack to complete on both client and server before running the src/server.

Why webpack needs to be configured from tools/start.js (it doesn't)

What is the webpack configuration file for, after all, if it doesn't suffice?

This is a good question and one that the authors should reconsider potentially. The reason that there is any webpack configuration in start.js owes to the fact that start.js is specifically a script to start running the server on a local development environment and not in production. So, certain features - namely the much-touted HMR or hot module replacement - require additional configuration but only apply in development and not in production or release configuration.

The authors of rsk opted to essentially add the relevant configuration for HMR in the tools/start.js file rather than to default with the configuration and remove it from other places, which makes sense.

But it makes better sense to segregate this aspect of bootstrapping from the start method, if only to make things a bit less hazy for the mere mortals out there. Here is a quick attempt at that separation, a new file tools/startFromWebpackConfig.js:

import Browsersync from 'browser-sync'
import webpack from 'webpack'
import webpackMiddleware from 'webpack-middleware'
import webpackHotMiddleware from 'webpack-hot-middleware'
import run from './run'
import runServer from './runServer'
import clean from './clean'
import copy from './copy'

const DEBUG = !process.argv.includes('--release')

export function applyIfTargetIsNotNode (webpackConfig, toApply) {
  webpackConfig.filter(x => x.target !== 'node').forEach(config => {
    toApply(config)
  })
  return webpackConfig
}

export function applyHmr(config) {
  /* eslint-disable no-param-reassign */
  config.entry = ['webpack-hot-middleware/client'].concat(config.entry)
  config.output.filename = config.output.filename.replace('[chunkhash]', '[hash]')
  config.output.chunkFilename = config.output.chunkFilename.replace('[chunkhash]', '[hash]')
  config.plugins.push(new webpack.HotModuleReplacementPlugin())
  config.plugins.push(new webpack.NoErrorsPlugin())
  config
    .module
    .loaders
    .filter(x => x.loader === 'babel-loader')
    .forEach(x => (x.query = {
      ...x.query,

      // Wraps all React components into arbitrary transforms
      // https://github.com/gaearon/babel-plugin-react-transform
      plugins: [
        ...(x.query ? x.query.plugins : []),
        ['react-transform', {
          transforms: [
            {
              transform: 'react-transform-hmr',
              imports: ['react'],
              locals: ['module'],
            }, {
              transform: 'react-transform-catch-errors',
              imports: ['react', 'redbox-react'],
            },
          ],
        },
        ],
      ],
    }))
  /* eslint-enable no-param-reassign */

  return config

}

export function applyHmrToWebpackConfig (wConfig) {
  return applyIfTargetIsNotNode(wConfig, applyHmr)
}

export function runWebpackGetBundler(wConfig) {

  return webpack(applyHmrToWebpackConfig(wConfig))

}

export function runWebpackGetBundlerAndMiddlewares (wConfig) {
  console.log(`Running webpack get bundler`)
  const bundler = runWebpackGetBundler(wConfig)

  console.log(`Gettuing webpackMiddleware`)
  const wpMiddleware = webpackMiddleware(bundler, {
    publicPath: wConfig[0].output.publicPath,
    stats: wConfig[0].stats,
  })

  console.log(`Getting hotMiddleware`)
  const hotMiddlewares = bundler.compilers
    .filter(compiler => compiler.options.target !== 'node')
    .map(compiler => webpackHotMiddleware(compiler))

  return {
    bundler,
    wpMiddleware,
    hotMiddlewares,
  }
}

export function startServerWithConfiguration (wConfig, resolve): void {
  console.log(`Entering startServerWithConfiguration printed below`)
  console.log(`${wConfig}`)

  console.log(`Running webpack bundler and configuring middleware`)
  const {
    bundler,
    wpMiddleware,
    hotMiddlewares
  } = runWebpackGetBundlerAndMiddlewares(wConfig)

  let handleServerBundleComplete = () => {
    console.log('starting')
    runServer((err, host) => {
      if (!err) {
        const bs = Browsersync.create()
        bs.init({
          ...(DEBUG ? {} : { notify: false, ui: false }),

          proxy: {
            target: host,
            middleware: [wpMiddleware, ...hotMiddlewares],
          },

          // no need to watch '*.js' here, webpack will take care of it for us,
          // including full page reloads if HMR won't work
          files: ['build/content/**/*.*'],
        }, resolve)
        handleServerBundleComplete = runServer
      }
    })
  }

  console.log(`Setting handler for server bundle completion`)
  bundler.plugin('done', () => handleServerBundleComplete())
  console.log(`Exiting startServerWithConfiguration. All done!`)
}

export default startServerWithConfiguration

In the start method, the following now suffices:

async function start() {
  await run(clean)
  await run(copy.bind(undefined, { watch: true }))
  await new Promise(resolve => {
    startFromWebpack(webpackConfig, resolve)
  })
}

This is certainly not a necessary modification and there are better ways of accomplishing this end, but it does illustrate the point.

For now, set aside any questions about why or what is being configured for webpack

Although it would be nice to know all the details, it isn't all that important to know and it certainly isn't important in the context of understanding how the npm run start actually starts the application specifically.

From npm run start to tools/runServer.js

Let's recall what has been covered thus far:

  • The interesting subset of npm run scripts (those that start or build or bundle the application) start by calling tools/run and providing an argument, process.argsv[2], of which tool to start
  • The tools/run.js script is a very simple wrapper script that can be called multiple times and will clean up the require cache all by itself
  • The tools/start.js script is called by npm run start and does three things before starting src/server.js: clean, copy, and configure webpack
  • The webpack configuration that occurs in tools/start.js by default can and probably ought to be moved to a separate set of smaller methods and in another file, as suggested above
  • The line at the end of the default start method, bundler.plugin('done', () => handleServerBundleComplete()), is responsible for starting the server when the webpacking and bundling is done.
  • The handleServerBundleComplete method calls the runServer method with an argument in the form of a callback (Error, String) => void.

tools/runServer.js

When the start method specifies a callback for the completion of bundler, that callback is itself another call to runServer with a callback in a particular shape:

runServer((err, host) => {
  if (!err) {
    const bs = Browsersync.create()
    bs.init({ ...glossing over... }, resolve)
    handleServerBundleComplete = runServer
  }
})

That callback is the only argument to runServer, which, as its name suggests, kicks off the src/server.js.

Starting src/server.js involves:

  • Using the cp = require('child_process') method cp.spawn to spawn a new child process that runs node
  • Handling events from the child process server = cp.spawn('node', [serverPath]), options
    • Handling server.once('exit')
    • Handling server.stdout.on('data') or whenever the server has output data
    • Handling server.stderr.on('data') or whenever the server has error data

The first time tools/runServer.js is imported, the following housekeeping takes place immediately:

  • The const serverPath is set in memory from the webpack configuration to the entry point src/server.js
  • The server child is declared but not assigned (it will be assigned when runServer calls cp.spawn for a new child process)
  • The exit event of process (the parent) is set up to kill any children (server) with process.on('exit', () => { if (server) server.kill(SIGTERM) })

Besides those three things taking place on the first import, everything else about runServer.js is found in the single method runServer(cb), and everything it does is outlined in the previous section about starting `src/server.js

runServer(cb)

Nothing in runServer is particularly important for developers to understand but it is sufficiently simple and short that understanding it takes very little time or effort.

Setting cbIsPending = true

The first thing that happens in runServer is the assignment of an important local variable cbIsPending to !!cb.

function runServer(cb) {
  let cbIsPending = !!cb

This is a bit sloppy, but it works. Doing !!cb is exactly what it appears - that is, the same as let cbIsPending = !(cb == true) or let cbIsPending = (cb == true) != true). The purpose of the double cast is to cast cbIsPending to a boolean that indicates whether or not cb is null. This will be used later to determine whether or not the callback should be executed - and a null callback, at the very least, should not be executed.

The output handler onStdOut

The next part is a function onStdOut(data) that logs any stdout output to the console with process.stdout.write, and only one time, at most, also runs the callback if it is set. The one time it may run the callback is when match is true and cbIsPending is true.

  function onStdOut(data) {
    const time = new Date().toTimeString();
    const match = data.toString('utf8').match(RUNNING_REGEXP);

    process.stdout.write(time.replace(/.*(\d{2}:\d{2}:\d{2}).*/, '[$1] '));
    process.stdout.write(data);

    if (match) {
      server.stdout.removeListener('data', onStdOut);
      server.stdout.on('data', x => process.stdout.write(x));
      if (cb) {
        cbIsPending = false;
        cb(null, match[1]);
      }
    }
  }

Now match is only true when the server outputs a message that matches the regex, like:

The server is running at http://localhost:3001

The first time and probably the only time this will occur is when the server first starts, which is where the callback cb comes into play.

If match is true and cbIsPending, meaning that cb is not null and hasn't already been called, the program will set cbIsPending to false (preventing another run) and then run it with the parameters cb(null, match[1] or the hostname.

Assigning server with a cp.spawned child

The following kills any existing server if it is set, then spawns and assigns to server a new node process set to serverPath.

  if (server) {
    server.kill('SIGTERM');
  } 

  server = cp.spawn('node', [serverPath], {
    env: Object.assign({ NODE_ENV: 'development' }, process.env),
    silent: false,
  });

If cbIsPending is still pending when the server exits, throw an Exception

The reasoning here is that the server child process should not exit by itself without the callback being executed one time.

  if (cbIsPending) {
    server.once('exit', (code, signal) => {
      if (cbIsPending) {
        throw new Error(`Server terminated unexpectedly with code: ${code} signal: ${signal}`);
      }
    });
  }

Assign the data and error handlers with stdout.on and stderr.on

Pretty self-explanatory.

  server.stdout.on('data', onStdOut);
  server.stderr.on('data', x => process.stderr.write(x));
  return server;
}

src/server.js

Once tools/runServer.js starts node with the right serverPath, the src/server.js file will start.

src/server.js is a bootstrapper for the server, specifically, for express web server.

The rkt authors include quite a bit of special configuration in src/server.js to parse. This configuration is necessary though because it accomplishes quite a bit, most of which is presented below in order of appearance:

  1. Sets up static rendering
  2. Sets up a cookie parser
  3. Sets up a request parser for URL-encoded, forms, and JSON
  4. Sets up jwt-based authentication for Express
  5. Sets up passport-based Facebook OAuth authentication
  6. Sets up graphql

What is missing from the list above, of course, is the actual setup of the routing to render the routes that are the point of the application.

All of the above is good to know and understand, and will be covered later, but for now, the routing setup is a far more pressing matter to cover. That magic part of the server setup takes place over about 30 lines, lines 85 to 115, in the src/server.js file.

The routing setup in src/server.js

All of the server-side routing is handled in a single call to app.get, shown below with comments and additional formatting added for convenience. The application.get method in express is a shortcut method to set up middleware for handling HTTP/GET requests, which are of course the kind produced by browsers when traversing links and addresses. The comments should generally suffice to explain the function call except for a few details about the call to UniversalRouter.resolve:

app.get('*', async (req, res, next) => {

  /***** OUTER TRY LOOP ****/
  
  try {
  
  /***** SETUP PARAMETERS FOR CALL TO UniversalRouter ****/
    
    let css = []
    let statusCode = 200
    const data = { title: '', description: '', style: '', script: assets.main.js, children: '' }
    
  /***** CALL (blocking) UniversalRouter(routes, configuration, renderFn) ****/

    await UniversalRouter.resolve(routes, {
      path: req.path,
      query: req.query,
      context: {
        insertCss: (...styles) => {
            // eslint-disable-line no-underscore-dangle, max-len
            styles.forEach(style => css.push(style._getCss())) 
        },
        setTitle: value => (data.title = value),
        setMeta: (key, value) => (data[key] = value),
      },
      render(component, status = 200) {
        css = []
        statusCode = status
        data.children = ReactDOM.renderToString(component)
        data.style = css.join('')
        return true
      },
    })
    
    /***** WHEN UniversalRouter CALL DONE, RENDER/ASSIGN html ****/

    const html = ReactDOM.renderToStaticMarkup(<Html {...data} />)

    /***** SET statusCode, SEND html response *****/

    res.status(statusCode)
    res.send(`<!doctype html>${html}`)
  } catch (err) {
  
    /***** HANDLE ERROR BY SENDING IT TO OTHER MIDDLEWARE with next(err) *****/
  
    next(err)
  }
})

The birds-eye view of what takes place is the following and in sequential order:

  1. Within a try/catch block, assign some local variables including data, which will be manipulated by UniversalRouter
  2. Call UniversalRouter.resolve and wait for it to finish
  3. Render html by calling ReactDOM to render the Html component, providing data
  4. Set the response status code and send the response with the html
  5. On any error, send it to the middleware loop with next

The configuration provided to UniversalRouter.resolve is of obvious interest and can be broken down into two types: required configuration and custom configuration.

Custom configuration provided to UniversalRouter.resolve

The custom configuration is found in the context key of the configuration object, which includes three low-level methods for adding CSS to the response, setting the title, and setting the meta.

Required configuration provided to UniversalRouter.resolve

The rest of the configuration shown is required configuration, and includes the path and query, which are easily derived from the request or req.path and req.query, as well as a special render function.

The render function is also quite simple, but makes more sense after (along with much of this explanation) after examining the code in its entirety, and especially after examining the Html component. All the render function achieves is:

  1. Setting the data.children property to ReactDOM.renderToString(component), where component is whatever needs to be rendered.
  2. Setting the data.style property to the imploded value of the css array, which is set by the context method insertCss during the rendering call in the previous step

The rest of src/server.js

For the purposes of understanding how the application works, the rest of src/server.js is definitely important and vital, but not as important as some of the other points left to cover. So for now, table questions about the rest of src/server.js, and continue to src/components/Html.js, which is the very last piece of this part of the high-level puzzle - and begs the right questions about the next part, the client part.

src/components/Html.js

The previous section reached the end of the server pipeline, the part of the application in which the server sends the response from the request.

The most critical thing to understand is how the UniversalRouter plays a major role in the bridging of server and client, or rather, src/server.js and src/client.js.

With the information covered thus far it should be clear how the UniversalRouter fits into the picture - when src/server.js is imported, it makes a bunch of configuration-related calls, including an important one to app.get to handle GET requests. In that call to set up handling, the UniversalRouter is itself configured with a similar call, from where the render function is defined, rendering finally happens, and a response is sent back.

However, there is still a smaller missing link that must be addressed: what, exactly, does that response contain?

Of course, one could argue that since much of the server configuration has been skipped thus far, and much of that configuration pertains to request handling of other sorts, it follows that there are many other missing links that have yet to be covered.

That configuration will be covered later, but it does not relate very much to the most critical piece to understand - how the server and client bridge together. At least, not directly.

The Html component, meaning a subclass of React.Component, does relate because it defines the "wrapper" page that serves up the src/client.js application. Every time a GET request is handled successffuly, and even in the case of errors as will later be discovered, the Html component is the component being rendered upon handling.

The source of Html.js

The following is an abridged reproduction of Html.js source, missing some of the machinery and details that are not necessary to understand how it works:

function Html({ title, description, style, script, children }) {
  return (
    <html className="no-js" lang="">
      <head>
        <title>{title}</title>
        <meta name="description" content={description} />
        <style id="css" dangerouslySetInnerHTML={{ __html: style }} />
      </head>
      <body>
        <div id="app" dangerouslySetInnerHTML={{ __html: children }} />
        {script && <script src={script} />}
        {/* Google Analytics code removed for clarity. */}
      </body>
    </html>
  );
}

Html.propTypes = {
  title: PropTypes.string.isRequired,
  description: PropTypes.string.isRequired,
  style: PropTypes.string.isRequired,
  script: PropTypes.string,
  children: PropTypes.string,
};

export default Html;

Recall that the data hash was provided to the UniversalRouter.resolve method and accessed from the render method to render individual components. The pertinent code is reproduced below:

const data = { title: '', description: '', style: '', script: assets.main.js, children: '' }

await UniversalRouter.resolve(routes, {
  path: req.path,
  query: req.query,
  context: {
    insertCss: (...styles) => {
        // eslint-disable-line no-underscore-dangle, max-len
        styles.forEach(style => css.push(style._getCss())) 
    },
    setTitle: value => (data.title = value),
    setMeta: (key, value) => (data[key] = value),
  },
  render(component, status = 200) {
    css = []
    statusCode = status
    data.children = ReactDOM.renderToString(component)
    data.style = css.join('')
    return true
  },
})

const html = ReactDOM.renderToStaticMarkup(<Html {...data} />)

It should be very clear by now that data is the props of Html, and further, that each member of data is used somehow in the rendering of Html.

  • The data.style are meshed together and then rendered using dangerouslySetInnerHTML in the style tag of the head
  • The data.children rendered by the ReactDOM.renderToString(component) call in render() and then assigned to the app div interior
  • The data.script is a path provided as assets.main.js, which is a build artifact; the path is set as a script src
  • The data.title is used to assign the <title>
  • The data.description is used to assign the <meta> description tag

The only pressing question really left outstanding is the perfect segue into the client world: how does data.children actually get assigned by the UniversalRouter? And how does data.style get assigned at all?

Configuring the UniversalRouter with the files in src/routes

The question of how routes are configured only makes sense to ask once it is clear how the configuration gets to matter in the first place. Now that the question of why it matters is resolved, it is time to examine how to use the UniversalRouter to define both client and server routes.

One of the (not shown) imports to src/server.js is the following:

import routes from './routes'

That import, in turn, actually resolves to src/routes/index.js, which is the only proper route configured. All other routes are its children, which makes sense, because its path is given as /, the root path.

src/routes/index.js

The entirety of the single application route is given below (in reality, it is wrapped with export default):

  path: '/',
  
  children: [home, contact, login, register, sandbox, content, error,],

  async action({ next, render, context }) {
    const component = await next()
    if (component === undefined) return component
    return render(<App context={context}>{component}</App>)
  }

path

The path defines the pattern of the uri that should trigger this handler. Since the root route has no parent, the path is just the part that comes after the hostname (and port) in the uri. If it is changed to /monkey/, all of the routes will suddenly be relative to /monkey/, and all of the paths underneath / besides monkey will remain unhandled by this router.

children

The children routes are evaluated IN ORDER of inclusion. They are defined relative to their parent, which in this case, means they are defined relative to the site root.

action({ next, render, context })

The route's action is the important part of the route. It defines how the route will handle a given request, as parameterized by the single object with the keys next, render, and context.

  • The next key is the first route handler to handle the given request after this one. To get the first children handler, call next()
  • The render key is the function for rendering stuff that was defined previously in the set up for src/server.js (recall that it is one of the data keys itself).
  • The context key is a key for holding custom contextual dependencies and information. In this case, thus far, it was configured to provide three useful functions from the server: insertCss, setMeta, and setTitle, which set the data keys for style, description, and title respectively.

With this in mind it is easy to understand what is happening in the root render function.

  1. Call the next() method to get the next handler's return value by awaiting its result
  2. Return undefined if next() returns undefined; otherwise, call the provided render function from the parent to render the App component with the given context and with the value of next() as its child

It is useful to understand how this all works together by examining a specific child route - for instance, the contact route.

The src/routes/contact route

The folder structure of the src/routes/contact folder includes three files: index.js, which is the module imported when requiring src/routes/contact, along with Contact.js and Contact.css. The index.js file is really simple:

  path: '/contact',

  action() {
    return <Contact />;
  },

Once again, the path is relative to the parent, which is the root, so to fire this route, hostname:port/contact would be the right way.

The contact route evidently has no children, which means that it has no further routes to call with next().

Finally, the action method is straightforward: return the Contact component in the same folder. If it did have children, it could call next() to have one of its children potentially handle the route. But it would be pointless to call in this case because it is known in advance that there are no children to call.

The Contact component

The Contact component is returned by this route. It is found in the same directory, which is a recurring pattern in this boilerplate's setup and a good practice: that is, route-specific Component subclasses are kept in the same place as routes, and thus seperated from more general Component subclasses found in src/components.

Its code, including its imports, is provided below:

import React, { PropTypes } from 'react'
import withStyles from 'isomorphic-style-loader/lib/withStyles'
import s from './Contact.css'

const title = 'Contact Us'

function Contact(props, context) {
  context.setTitle(title)
  return (
    <div className={s.root}>
      <div className={s.container}>
        <h1>{title}</h1>
        <p>...</p>
      </div>
    </div>
  )
}

Contact.contextTypes = { setTitle: PropTypes.func.isRequired }

export default withStyles(s)(Contact)

Quite a bit is actually happening here so it is important not to gloss over any of it.

The const title is set in the first line

The line context.setTitle(title) uses the context function defined way back in the server/src.js setup to give the lowly Contact component a means to set the <title> tag for the entire page so that it matches what it returns in the markup.

Pretty cool, right?

The returned JSX includes references to a CSS module s

The s module is derived from Contact.css, which is in turn served by webpack in a very particular way for this to all work. That will be covered later, but please note that the className rather than the class are set (class is a reserved keyword in JavaScript) and that the className set is not a string literal but a value from the object s. This is intentional because the actual rendered classes may or may not be named as they are defined in the stylesheet for good reasons that will be explained later.

But wait... how do the styles actually get included in the response?

Here's a hint: withStyles. It will be covered a bit later.

The Contact.contextTypes is set to define setTitle as a required function

This is not strictly necessary but good practice. In essence, this is like saying, "the class Contact will need a dependency setTitle provided in its context." Whether or not that is the case, the setTitle method's availability would have been determined elsewhere (specifically, in the chain of calls passing context from the server all the way to the parent route). But by defining the needs up front, the design is a bit less murky and if somebody forgets to provide some necessary context key, it will be clear what is happening.

withStyles(s)(Contact) - what's that all about?

For now, just know that withStyles takes as an input the imported CSS module and returns a function. That function in turn takes as an input a function that transforms a function into a Component equipped with the styles defined in the argument to withStyles.

A recap of what has been covered

In some sense, programming is all about wiring things together. Maybe it can be done in a more aesthetic way, or a more purposeful way, or a more telegraphic way, but one has to wire things together.

And in some sense, what has been covered in this section particularly and in the whole guide has been just that:

  1. npm run start is wired to a start script as defined by package.json
  2. The start script really calls tools/run.js start
  3. The tools/run.js script wraps the call to tools/start.js, which prepares webpack for development usage and then instructs webpack to start tools/runServer.js when it is finished bundling
  4. tools/runServer.js does just that: it runs the src/server.js in a child process using npm module child_process, but also implements logic to handle some key events with a user-defined callback
  5. src/server.js does a bunch of configuration of a new express application, but the most important configuration it does pertains to route handling, which works as follows:
    1. A single call to app.get() defines all the linkable routes (the GET handlers for the application)
    2. In the call to app.get, all requests are delegated to be handled by the UniversalRouter
    3. The UniversalRouter is configured to use the routes defined in src/routes
    4. The routes in src/routes are really a bunch of child routes defined in subfolders, each of which renders a special component for those routes handled by the route in question (there are some components that can be used for multiple routes that have not been covered so far).
    5. If a route in src/routes matches, the root route renders it and then returns an App component with the context it was provided and a child equal to the matched child's rendered html.
  6. If the app.get call concludes with some route being handled and returning html, it returns the html. Otherwise, it calls the next middleware or handler.

Another aspect of wiring, or way of thinking about wiring, is to examine dependencies between parts of the whole.

The flow from npm run start to a correctly-configured server is somewhat complicated but relatively linear.

Dependencies from the tools folder tend to be simplistic and minimal by design; most of the them are in the form of continuations or function pointers (closures).

Once the application starts in src/server.js, many dependencies are needed to configure and bootstrap the server, but most of them are outside one-time-use dependencies for very specific ends, like Facebook authentication or cookie parsing.

By the time application request handling kicks in, though, dependency analysis gets a bit more interesting and more potentially insightful.

data and context revisited

By far the most consequential data structure thus discussed has been the data argument provided to the Html component being rendered at the end of a route being handled by the app.get call in src/server.js.

It is consequential because it is a dependency of so many consequential-themself dependencies!

Here it is again:

const data = { title: '', description: '', style: '', script: assets.main.js, children: '' }

Shortly after it is first introduced, the context dependency is introduced and two methods are defined to manipulate data along with its lesser cousin, css:

context: {
    insertCss: (...styles) => {
        // eslint-disable-line no-underscore-dangle, max-len
        styles.forEach(style => css.push(style._getCss())) 
    },
    setTitle: value => (data.title = value),
    setMeta: (key, value) => (data[key] = value),
},

The context is particularly useful because it allows developers to arbitrarily introduce dependencies and share them from the top to the bottom. Recall that the Contact component defined a contextTypes which specified the need for a method setTitle. That method need is satisfied by the context defined and provided to UniversalRouter.

Contact.contextTypes = { setTitle: PropTypes.func.isRequired }

The context from this high level is passed through the initial route to the child route and eventually to the component without any of the glue typically involved. There is no reference to context passed from the root-level route to the child, only a call to next.

action() {
    return <Contact />;
},

But the context is provided to the Contact indirectly: through the route handler when it renders the App component:

async action({ next, render, context }) {
    const component = await next()
    if (component === undefined) return component
    
    return render(
        <App context={context}>
            {component}             // <---- in this case, component is <Contact />
        </App>
        )
}

Note that by dealing in Component instances rather than raw HTML it is possible to late bind such dependencies - and to define them all in one convenient place like src/server.js:

context: {
    something_We_Need_Every_Time,
    something_We_Need_Sometimes,
    something_else_We_Need_Sometimes,
    favoriteColor,
    // something_we_dont_need_at_this_time
},

...and yet, the context can be set over over-written at a lower level when necessary:

val specialContext = {
    context.something_We_Need_Every_Time,
    something_else_We_Need_Sometimes,
    favoriteColor: 'blue',                  // <---- "overriding" the favoriteColor
    something_we_dont_need_at_this_time: 15 // <---- later binding
},

The render function sets the data.children and data.style values. The render function is itself passed first into the argument for UniversalRouter.resolve, then as an argument to the root-level route in src/routes/index.js:

render(component, status = 200) {
    css = []
    statusCode = status
    data.children = ReactDOM.renderToString(component)
    data.style = css.join('')
    return true
},

Finally, data is used to render Html:

const html = ReactDOM.renderToStaticMarkup(<Html {...data} />)

Note of course that by visiting again the Html component it is clear how and why each data member is set up and used as it is.

You can’t perform that action at this time.
You signed in with another tab or window. Reload to refresh your session. You signed out in another tab or window. Reload to refresh your session.
Press h to open a hovercard with more details.