Create your own React-Rails v4 project configured for amazing performance with code splitting, lazy loading, route bundling, and hot loaded stylesheets

A beautiful marriage

I spent the last week working on converting my company’s JQuery-managed frontend to a pure React-Rails environment. It took a few iterations of starting and restarting before landing on a system that I think works well, and I’d like to share our setup with you today.

For the purposes of this article, I’ll assume that you have some prerequisite libraries installed such as React, Rails, Node, etc., and that your machine is configured for development.


Setting up react-rails

There are several libraries that integrate React directly with the Rails asset pipeline. When I started, we were using a couple of different ones, but I decided to rely solely on react-rails.

react-rails ships with a couple of different utilities, chief among them being webpacker and react_ujs. It uses the former to build a webpack configuration object dependent upon your app’s configured environment, and it uses the latter to render React components inside of Rails templates.

At the time of writing, react-rails shipped with v3 of the webpacker library. In order for us to use some newer features of webpack, we’ll want to upgrade to v4. Throughout the rest of this article, we’ll be working with webpacker@4.0.0.rc.2.

There are three sections of our configuration that we’ll be interested in:

  1. config/webpacker.yml — Webpacker configuration
  2. config/webpack/environment.js — Webpack configuration object
  3. .babelrc — Babel configuration

It can be easy to confuse webpacker and webpack. Just remember that webpacker is the Ruby library and webpack is the Javascript compiler.

For your package.json file, replace your dependencies and resolutions with the following, and then run npm install or yarn install:

{
"devDependencies": {
"@babel/preset-react": "^7.0.0",
"@rails/webpacker": "^4.0.0-rc.2",
"babel-plugin-require-context-hook": "^1.0.0",
"babel-plugin-transform-react-remove-prop-types": "^0.4.21",
"postcss-cssnext": "^3.1.0",
"react": "^16.7.0",
"react-dom": "^16.7.0",
"react-router": "^4.3.1",
"react-router-dom": "^4.3.1",
"react_ujs": "^2.4.4",
"webpack-merge": "^4.2.1"
},
"resolutions": {
"babel-core": "7.0.0-bridge.0",
}
}

Follow the instructions provided in the react-rails documentation, and then, before opening your webpacker.yml file, make sure to include the following lines in your gemfile and run bundle install:

gem 'webpacker', '~> 4.0.0.rc.2'
gem 'react-rails'

webpacker.yml

This file contains configurations for your app’s different environments. We’re really only concerned with the top section, default, as it’s passed to the rest of the environments.

This is what you should see when you first open the file (I’ve removed the comments):

default: &default
source_path: app/javascript
source_entry_path: packs
public_output_path: packs
cache_path: tmp/cache/webpacker
check_yarn_integrity: false
webpack_compile_output: false
  resolved_paths: []
cache_manifest: false
  extract_css: false
  extensions:
- .mjs
- .js
- .sass
- .scss
- .css
- .module.sass
- .module.scss
- .module.css
- .png
- .svg
- .gif
- .jpeg
- .jpg

Here are the important bits:

  • source_path and source_entry_path are concatenated together with a glob pattern (built using the extensions attribute) in order to build bundles for each file in your project.
  • Your bundles are compiled to public_output_path, and cached to cache_path.
  • Webpack will lookup additional modules listed in resolved_paths when compiling your bundles.

We’ll be storing our frontend code in app/react, and we will have a simple Javascript file that utilizes the react_ujs module to create an entrypoint for our router component.

We’re also going to be using JSX within React, so we want to make sure we include the .jsx file extension in our configuration.

With that being said, replace the contents of your config/webpacker.yml file with the following:

default: &default
source_path: app/react
source_entry_path: entrypoints
public_output_path: packs
cache_path: tmp/cache/webpacker
check_yarn_integrity: false
webpack_compile_output: false
resolved_paths: []
cache_manifest: false
extract_css: false
extensions:
- .jsx
- .js
- .sass
- .scss
- .css
- .module.sass
- .module.scss
- .module.css
- .png
- .svg
- .gif
- .jpeg
- .jpg

environment.js

Webpacker uses this Javascript file to import a custom class instance, environment, which is ultimately used to export a configuration object for webpack.

We can use this file to customize the output that is used by webpack in order for us to implement various plugins and loaders. You can read more about this file and its options in the documentation.

In order to make the router system work properly, we want to use the split chunks plugin. We’re also going to install a plugin to help us in creating a manifest file. Finally, we want to include the babel-loader plugin so that we can use ES6 and beyond.

This configuration will ultimately tell webpack to run our code through Babel, and to create a single asset bundle for all of our node_modules that we can then include in our default Rails template.

In your config/webpack/environment.js file, replace any existing code with the following:

const {environment} = require('@rails/webpacker')
const WebpackAssetsManifest = require('webpack-assets-manifest')
environment.config.merge({
optimization: {
splitChunks: {
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
chunks: 'all'
}
}
}
}
})
environment.plugins.insert(
'Manifest',
new WebpackAssetsManifest({
entrypoints: true,
writeToDisk: true,
publicPath: true
})
)
environment.loaders.prepend(
'jsx',
{
test: /\.(jsx|js)x?$/,
exclude: /node_modules/,
loader: 'babel-loader'
}
)
module.exports = environment

.babelrc

A very simple file containing a single JSON object with our Babel configuration. We will need to use Babel in order for our router to dynamically import the components that React lazy loads for us.

Create a .babelrc in the root directory of your project and add this code to it:

{
"presets": [
"@babel/preset-env",
"@babel/preset-react"
],
"plugins": [
"@babel/plugin-syntax-dynamic-import",
"@babel/plugin-proposal-object-rest-spread",
[
"@babel/plugin-proposal-class-properties",
{
"spec": true
}
]
]
}

Babel v7 introduces another root file that can be used in addition to our .babelrc file.

So, we’ll want to create babel.config.js in the root of our project with the following code:

module.exports = function(api) {
var validEnv = ['development', 'test', 'production']
var currentEnv = api.env()
var isDevelopmentEnv = api.env('development')
var isProductionEnv = api.env('production')
var isTestEnv = api.env('test')
if (!validEnv.includes(currentEnv)) {
throw new Error(
'Please specify a valid `NODE_ENV` or ' +
'`BABEL_ENV` environment variables. Valid values are "development", ' +
'"test", and "production". Instead, received: ' +
JSON.stringify(env) +
'.'
)
}
return {
presets: [
isTestEnv && [
require('@babel/preset-env').default,
{
targets: {
node: 'current'
}
}
],
(isProductionEnv || isDevelopmentEnv) && [
require('@babel/preset-env').default,
{
forceAllTransforms: true,
useBuiltIns: 'entry',
modules: false,
exclude: ['transform-typeof-symbol']
}
],
[
require('@babel/preset-react').default,
{
development: isDevelopmentEnv || isTestEnv,
useBuiltIns: true
}
]
].filter(Boolean),
plugins: [
require('babel-plugin-macros'),
require('@babel/plugin-syntax-dynamic-import').default,
isTestEnv && require('babel-plugin-dynamic-import-node'),
require('@babel/plugin-transform-destructuring').default,
[
require('@babel/plugin-proposal-class-properties').default,
{
loose: true
}
],
[
require('@babel/plugin-proposal-object-rest-spread').default,
{
useBuiltIns: true
}
],
[
require('@babel/plugin-transform-runtime').default,
{
helpers: false,
regenerator: true
}
],
[
require('@babel/plugin-transform-regenerator').default,
{
async: false
}
],
isProductionEnv && [
require('babel-plugin-transform-react-remove-prop-types').default,
{
removeImport: true
}
]
].filter(Boolean)
}
}

Building the entrypoint

We’re going to have a single entrypoint for React, which we will serve up to our users through a default Rails template. Since we’ve reconfigured our folder structure, we will want to make sure to update the context that is provided to rails_ujs in this file. There is more information in the react-rails documentation.

Create a new file at app/react/entrypoints called application.js and insert the following code into it:

const componentRequireContext = require.context('../components/router')
const ReactRailsUJS = require('react_ujs')
ReactRailsUJS.useContext(componentRequireContext)

Creating the router component

Our router component will utilize React’s lazy() function along with Babel’s dynamic import plugin to load in the Javascript bundles for our separate routes, in addition to the obvious fact of creating the routes for our React application.

I prefer to keep stylesheets separate from other logic, so you will not be seeing any inline styles. Rather, we will create a separate file and then import it into our components individually. These styles will then be hot loaded for us and scoped to their specific components, so you don’t have to worry about unique class names.

Create two files at app/react/components/router, index.scss and index.jsx, and paste the following into the JSX file:

import React, {Suspense, lazy} from 'react'
import {BrowserRouter, Route, Switch} from 'react-router-dom'
import './index.scss'
const HelloWorld = lazy(() => import('routes/helloWorld'))
class Router extends React.Component {
render() {
return (
<BrowserRouter>
<Suspense fallback={<div>Loading...</div>}>
<Switch>
<Route exact path="/" component={HelloWorld} />
</Switch>
</Suspense>
</BrowserRouter>
)
}
}
export default Router

Here we are using React’s lazy load feature to import a HelloWorld component and then we assign that component to the index route.

We use the Suspense component in order to provide a fallback view while the path is being loaded or in case it can’t be found.

In app/react/routes/helloWorld create two more files, index.jsx and index.scss. Within the JSX file, paste the following code:

import React from 'react'
import './index.scss'
class HelloWorld extends React.Component {
render() {
return (
<span className="hello-world">Hello world!</span>
)
}
}
export default HelloWorld

And in the SCSS file:

span.hello-world {
font-size: 16px;
font-weight: bold;
font-family: Arial;
}

Constructing the Rails template

Now that we’ve got our webpack and babel configurations setup, and our router component created, we can tie it all together in a single default Rails view.

In my company’s config/routes.rb file, I have installed a simple GET catchall route:

get ':non_api_nor_static', to: 'pages#default', constraints: {
:non_api_nor_static => /[\w\d\-\_\/]+/
}

This route uses a very basic method within the PagesController file:

def default
@current_user = current_user
  render('pages/default/page', layout: 'layout_default')
end

Instead of using the standard convention of loading your React components and then having those components polling the API for data, react-rails lets you pass data directly into React components as props from inside of a Rails template.

I think that defeats the purpose of React insofar as you want your application to load quickly, so that the user isn’t staring at a white screen while the page loads, but keep in mind that it is possible for you to do this by simply passing the variables into your rendered view from the controller (such as @current_user is above).

For our layout, we can use something very simple for the purpose of loading in our vendor.js bundle:

<!DOCTYPE html>
<html>
<head>
<title>Hello World!</title>
    <%= javascript_pack_tag 'vendor' %>
    <%= yield :head %>
</head>
<body>
<%= yield :body %>
</body>
</html>

Then, in our page.html.erb file, we can simply use the react-rails view helper react_component() to render our router:

<% content_for :head do %>
<%= javascript_pack_tag 'application' %>
<% end %>
<% content_for :body do %>
<%= react_component("index", {
currentUser: current_user
}) %>
<% end %>

This will load in the application.js entrypoint we created, which provides us the context to load in the app/react/components/router/index.jsx file.

The router will identify the user’s current path, and lazily load the necessary React component for it.


Running the application

All that’s left to do now is to run rails s from the console and navigate to localhost:8000.

Landing on the index should load in a 27KB script containing your router code and the CSS hot loading module. The CSS module doesn’t even need to download your index.scss files; it simply injects the code directly into your page/component.

Finally, the router component will load in the HelloWorld bundle script separately.

You can see all of these requests happen individually within your browser’s developer tools’ network tab.


Closing thoughts and comments

As I said in my opening paragraphs, my company’s previous frontend was entirely created using JQuery. This meant that for each page, we were having our users load in several megabytes of vendor scripts, as well as the scripts for the application itself, and individual stylesheets from libraries and those that we created ourselves.

Altogether, this meant for an extremely slow loading site. For a product in its alpha stages such as ours, this isn’t a big deal, but this type of problem could spell demise for company’s with existing user bases.

After converting our application’s frontend to the process described above, our initial page load is now less than 50KB. We’ve even discussed the possibility of creating a mobile application using Electron, and considered the fact that we could make it offline-capable.

What do you think of this process? How does it differ from what your company has implemented? Is there any thing that could be improved further? Or are you having problems with this setup? Let me know in the comments below.