React with Rails in a real app

WARNING: this post is LONG.

This post will be a re-cap of my first hand experience with adopting ReactJS in an existing Rails application, which is in production and used by real users.

We have all seen enough ‘template’, ‘boilerplate’ or ‘starter’ apps showing us how to move our Rails front end development to React. They all work great, if you start from scatch or if your Rails app is still small (meaning your can afford to rewrite a lot of existing front end code).

Unfortunately, it’s not my case. My Rails application’s first commit was done on 9th of March 2012. The app has been developed as a typical Rails app until Aug 2015 (when I introduced React to the dev stack). That’s about 3.5 years of your typical “Rails way” development. This means a lot of HAML, SCSS, Coffeescript files. I simply cannot afford to rewrite everything from scatch.

I obviously started with react-rails and react-router-rails. The issue with my-asset-rails gems is always about the upgradability. At the time writing this post, HEAD of react-router-rails is still pinned to react-router 0.13.3. react-router on npm is already at 2.0.1. Since I am not using server side render, using my-asset-rails gems to just use their UJS hooks doesn’t seem to be a smart idea.

As someone who’s big on being pragmatic, I removed react-rails and react-router-rails and simply registered the following JS object in the head of the rails layout file

// html head inside application.html.haml
var LazyReactComponent = {
  react_component_name: null,
  react_component_props: {},
  dom_id: null,
  type: "component",
  lazy_mount_react_component: function() {
    ReactDOM.render(
      React.createElement(eval.call(window, this.react_component_name), this.react_component_props),
      document.getElementById(this.dom_id)
    );
  },

  lazy_mount_react_router: function() {
    var routerNode = document.getElementById(this.dom_id);
    var routes = eval.call(window, this.react_component_name);

    ReactDOM.render(React.createElement(ReactRouter, {history: ReactRouterHistory}, routes), routerNode);
  }
}

In my application, I decided to render at most one main react component for any given Rails route. There’s no magic in the above code. It simply plays within the rules I set on my own app. It lays out an object with a few values to be filled by the Rails view file (discussed later) and a couple of mount functions (One for plain React components; the other for react-router wrapped components). The reason why the above JS code needs to be included in the layout header is that my app’s main application.js is loaded async at the end of the HTML body using the following JS code.

// before closing body inside application.html.haml
function downloadJSAtOnload() {
  var element = document.createElement("script");
  element.src = "#{javascript_path('application')}";
  document.body.appendChild(element);
}

if (window.addEventListener) {
  window.addEventListener("load", downloadJSAtOnload, false);
} else if (window.attachEvent) {
  window.attachEvent("onload", downloadJSAtOnload);
} else {
  window.onload = downloadJSAtOnload;
}

The application.js manifest file is the beast. It requires for all those good old coffeescripts, as well as 2 special pieces.

  • One being the webpack transpiled js bundle file, dist_react_components.js, for all of my React components
  • The other being a special mount_react_component.js file.

The mount_react_component.js file is super simple. When it comes to life (loaded on to DOM asynchronously), it invokes one of the LazyReactComponent’s mount functions depends on the LazyReactComponent.type value set by the Rails view file (I’ll go into later).

// mount_react_component.js
if(LazyReactComponent.react_component_name !== null && LazyReactComponent.dom_id !== null) {
  if (LazyReactComponent.type === "router") {
    LazyReactComponent.lazy_mount_react_router();
  } else {
    LazyReactComponent.lazy_mount_react_component();
  }
}

The dist_react_components.js file exposes all mountable React backed UI components in a JS object literal. It’s something like below. Note that there’s no export, since this file would be used outside the module system. It’s included by Rails’ application.js.

// dist_react_components.js
import { Router, browserHistory } from "react-router";
import React from "react";
import ReactDOM from "react-dom";
import ActivityReport from "./activity_report/components/app";

Envisio.React = {
  // The following objects are required by the LazyReactComponent. I expose them here because I'm only loading in React and ReactRouter using NPM.
  React: React,
  ReactRouter: Router,
  ReactDOM: ReactDOM,
  ReactRouterHistory: browserHistory,

  // All React backed UI components are here. I use ActivityReport as an example.
  ActivityReport: ActivityReport
};

After I write the ActivityReport React component, I now only have 1 thing left to do. If I have a Rails route like below

# routes.rb
# contrived Rails route example
get '/activity_report', to: 'ActivityReports#index'

We all know how to do the normal Rails controller, action, view stuff. The only thing interesting here is the view file, activity_reports/index.html.erb

<!-- activity_reports/index.html.erb -->
<% title 'Activity Report' %>

<div id="activity-report-react"></div>

<%= javascript_tag do %>
  LazyReactComponent.react_component_name = "Envisio.React.ActivityReport";
  LazyReactComponent.dom_id = "activity-report-react";
  LazyReactComponent.react_component_props = {initialPropsThatYouWantToPassToClientSide: {}};
<% end %>

JS snippet is actually being written in the erb view file. It simply sets up the required values on the LazyReactComponent, make LazyReactComponent’s mount methods ready to be invoked by mount_react_component.js discussed above.

What’s described above has been a journey for me. Many trials and errors. My goal is to gradually introduce React to an exisitng Rails team working on a Rails app without compromising team’s productivity. After about 6 months of pushing forward, I can proudly say I achieved my initial goal. We still build assets using the Rails asset pipeline. During development, only extra step for developers is to remember running npm run watch-js in their local terminal consoles. The npm watch-js script is plain simple, webpack --progress --colors --watch. It bridges to webpack to transpile ES7/ES6/JSX to ES5 javascript code, which will then be used by Rails asset pipeline for rake asset:precompile.

In future posts, I’ll discuss more about my learnings around react-router and flux.

Published: 2016-03-29
blog comments powered by Disqus