Log In

Blog

Building a React App in Simpl.js

posted October 21, 2018, by Klaus

React.js is a popular javascript library for building dynamic user interfaces using a declarative programming style. In this article we will walk through an implementation of a simple web application—the Blog app—that uses React along with the webapp and html modules as part of a modern and extensible component-based web framework.

Features

The basic web framework described here includes the following features:

It does not include the following:

Defining Views

A View consists of a React component factory function and, optionally, a CSS style object, an includes object with external style or script URLs, and an array of other view names as dependencies. In this example, and in the full Blog app implementation, the views are declared in a single object by name:

var views = {
  form: {
    // becomes /css/form.css:
    style: {
      '.form': {backgroundColor: '#eee'}
    },
    includes: {
      styles: ['https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css'],
      scripts: ['https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js']
    },
    dependencies: ['toggle'],

    // becomes /js/form.js:
    component: function(site) {
      return function() {
        return ['form', {className: 'form', action: site.url('counter')},
          [site.components.toggle, {name: 'enabled', value: this.props.enabled}],
          ['button', 'Submit']
        ];
      };
    }
  },
  toggle: {
    // becomes /js/toggle.js:
    component: function(site) {
      // any code here runs once (server and client)
      return {
        componentDidMount: function() {
          // anything here runs only client-side for each element created
        },
        getInitialState: function() {
          return {checked: this.props.value};
        },
        render: function() {
          var name = this.props.name,
              checked = this.state.checked;
          return ['input', {type: 'checkbox', name: name, checked: checked, onChange: function(e) {
            this.setState({checked: e.target.checked});
          }.bind(this)}];
        }
      }
    }
  }
};

A view's React component is returned by the component function as a plain object for use with createReactClass. For convenience, a simple render function can also be returned, and the return value of render is itself run through a jsx helper function (described below) to allow for a simple array representation of React elements without a JSX compiler.

To create a React class, component also receives a site instance for access to other components and for constructing site URLs.

Rendering the Page

A site object is constructed on both the server and client with the function below. This object's init method creates the react components from the component factory functions above.

var site = function(components, resolve, paths, title) {
  var jsx = function(node) {
    // node is [type, props, (node...)] or [node] or string
    if (!Array.isArray(node)) return node;
    var type = node[0], props, children;
    if (!type || Array.isArray(type)) {
      type = React.Fragment;
      children = node;
    } else if (typeof node[1] == 'object' && !Array.isArray(node[1])) {
      props = node[1];
      children = node.slice(2);
    } else {
      children = node.slice(1);
    }
    return React.createElement.apply(null, [type, props].concat(children.map(jsx)));
  };
  var site = {
    components: components,
    url: function(name, args) {
      return resolve(name, args, {paths: paths});
    },
    init: function(root, props) {
      var createClass = React.createClass || createReactClass;
      Object.keys(components).forEach(function(name) {
        var component = components[name](site),
            render = component.render;
        if (typeof component == 'function') {
          render = component;
          component = {};
        }
        component.render = function() {
          return jsx(render.call(this));
        };
        components[name] = createClass(component);
      });
      if (root) { // runs on client only
        props.title = title; // add static sitewide props from config
        ReactDOM.hydrate(React.createElement(components.page, props), document.getElementById(root));
      }
    }
  };
  return site;
};

On the server, site is initialized once with all of the app's React components:

var components = Object.keys(views).reduce(function(components, name) {
  components[name] = views[name].component;
  return components;
}, {});

site(components, app.url, app.paths).init();

Meanwhile, res.render generates an HTML page with a view component and props using renderToString. To hydrate this markup on the client, the page includes the following scripts, in order:

Delivering Static Files

Stylesheets and scripts are delivered to the client from the app endpoints below. See the webapp module for details on web app routing.

app.get('/js/:view.js', function(req, res) {
  var name = req.params.view,
      // site.js is a special case
      fn = name == 'site' ? site : (views[name] || {}).component;
  if (!fn) return res.generic(404);
  var js = script(fn, name);
  if (md5(js) != req.query.v) return res.generic(404);
  res.end(js, {
    'Content-Type': 'text/javascript',
    'Cache-Control': 'public, max-age=3600'
  });
}, 'script');

app.get('/css/:view.css', function(req, res) {
  var view = views[req.params.view] || {};
  if (!view.style) return res.generic(404);
  var css = html.css(view.style);
  if (md5(css) != req.query.v) return res.generic(404);
  res.end(css, {
    'Content-Type': 'text/css',
    'Cache-Control': 'public, max-age=3600'
  });
}, 'stylesheet');

When called, res.render collects the needed style objects, in addition to the component functions as described earlier, of a given top-level view to generate the script and stylesheet URLs to include in the rendered page markup. A Cache-Control header in the response, along with a content hash value in the URL, allow the client to cache these resources while fetching new ones as soon as they are changed.