guisehn.com

Implementing your own helper to render React.js components on Ruby on Rails ERB templates

Recently I had the opportunity to use Ruby on Rails to develop an internal system for the company I work. It was a good exercise to remember how productive this framework can be, and how single page applications are not always the best choice for every problem.

The project has only one specific screen which has lots of user interaction without reloading, so we can use view libraries like React only on this specific screen, and leave Rails to manage all the other pages which are mostly CRUDs.

Initially, I decided to implement the interactions of this screen with jQuery solely, but it soon started to become complex, and that’s when React came into play.

Since webpacker was already present in the project, the only step I had to do to add React.js and JSX was to run rails webpacker:install:react to install the dependencies as described in their documentation.

Now, the next step was to render my React component on the interactive screen. There is a gem called React-Rails which allows us to include the component inside your ERB template by calling a helper function like:

<%= react_component('Hello', name: 'World') %>

…where Hello is the component name and {name: 'World'} is the props object passed to the component. Under the hood, this will make the server render a div tag with the component data (name and props) inside, and React-Rails will search for these tags on the client to mount the components in place when the DOM is ready.

React-Rails also has other features like server-side rendering, component rendering inside controllers, etc. Since the only feature I really needed from it was the view helper, I decided to leave it outside this project and implement the Rails helper and component-mounting JavaScript code on my own as an exercise.

Implementing the helper

The helper is the easiest part with only one line of method implementation. Just add a file react_helper.rb in app/helpers with the following code.

module ReactHelper
  def react_component(name, props = {})
    content_tag(:div, '', data: { react_component: { name: name, props: props } })
  end
end

This will add a react_component helper to your project and make it available to all your ERB templates. The helper will transform the Hello component example I shown earlier in something like:

<div data-react-component='{"name":"Hello","props":{"name":"World"}}'></div>

Now, we need to write the code that will search for these tags and mount the React components on the browser when the page loads.

Implementing the component-mounting JavaScript code

First, we need to define where the React components are going to be located in our project. Just as in the React-Rails gem, we’ll put them on app/javascript/components. Here’s the component used on the Hello example shown previously:

app/javascript/components/Hello.jsx

import React from 'react'

const Hello = props => (
  <div>Hello {props.name}!</div>
)

export default Hello

The next step is to write the code that will load and mount the components based on the tags generated by the helper. It will be placed under app/javascript/packs/react.js.

Let’s use require.context from Webpack to iterate over the components folder and mount an object where the keys are the component names (extracted from the file names), and the values are the React components (exported by the component files).

// This will be the object populated with the components
const components = {}

// Here, we load the files from the `app/javascript/components` folder
const context = require.context('../components', false, /\.jsx$/)
for (const key of context.keys()) {
  // Extract component name from the file path with a regular expression
  // `../components/Hello.jsx` becomes `Hello`
  const componentName = key.match(/([^/.]+)\.jsx/)[1]

  // e.g. components['Hello'] = Hello component definition
  components[componentName] = context(key).default
}

Now, let’s iterate over the div tags generated by the react_component Rails helper we’ve implemented previously and mount the components.

document.addEventListener('DOMContentLoaded', () => {
  // Get all tags generated by the `rails_component` helper.
  const mountingElements = document.querySelectorAll('[data-react-component]')

  for (const element of mountingElements) {
    // Extract name and props from the data present in the element
    const { name, props } = JSON.parse(element.getAttribute('data-react-component'))

    // Retrieve the component from the `components` object we mounted before,
    // and check if it exists.
    const component = components[name]
    if (!component) {
      console.log(`Component ${name} not found`)
      continue
    }

    // Mount the component with the props inside the element
    ReactDOM.render(
      React.createElement(component, props || {}),
      element
    )
  }
})

Here’s the full file with the ReactDOM inclusion.

app/javascript/packs/react.js

import React from 'react'
import ReactDOM from 'react-dom'

// This will be the object populated
const components = {}

// Here, we load the files from the `app/javascript/components` folder
const context = require.context('../components', false, /\.jsx$/)
for (const key of context.keys()) {
  // Extract component name from the file path with a regular expression
  // `../components/Hello.jsx` becomes `Hello`
  const componentName = key.match(/([^/.]+)\.jsx/)[1]

  // e.g. components['Hello'] = Hello component definition
  components[componentName] = context(key).default
}

document.addEventListener('DOMContentLoaded', () => {
  // Get all tags generated by the `rails_component` helper.
  const mountingElements = document.querySelectorAll('[data-react-component]')

  for (const element of mountingElements) {
    // Extract name and props from the data present in the element
    const { name, props } = JSON.parse(element.getAttribute('data-react-component'))

    // Retrieve the component from the `components` object we mounted before,
    // and check if it exists.
    const component = components[name]
    if (!component) {
      console.log(`Component ${name} not found`)
      continue
    }

    // Mount the component with the props inside the element
    ReactDOM.render(
      React.createElement(component, props || {}),
      element
    )
  }
})

We need to include app/javascript/packs/react.js into our application layout by adding the following code inside the head tag.

<%= javascript_pack_tag 'react' %>

Testing it

Now, let’s finally test it.

config/routes.rb

Rails.application.routes.draw do
  root to: 'home#index'
end

app/controllers/home_controller.rb

class HomeController < ApplicationController
  def index
  end
end

app/views/home/index.html.erb

<h1>My page</h1>
<%= react_component('Hello', name: 'World') %>

app/views/layouts/application.html.erb

<!DOCTYPE html>
<html>
  <head>
    <title>My project</title>

    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag    'application', media: 'all' %>
    <%= javascript_include_tag 'application' %>

    <%= javascript_pack_tag 'application' %>
    <%= javascript_pack_tag 'react' %>
  </head>

  <body>
    <div class="container page-content">
      <%= yield %>
    </div>
  </body>
</html>

And here we go!

Working example screenshot