Setting Up Rails 7 with React Using Esbuild

Edu Depetris

- Jul 16, 2024
  • React
  • Esbuild
  • Ruby On Rails
In this article, I’ll explain how to set up Rails 7 with React using Esbuild without any extra gem dependencies other than the default jsbundling-rails. Additionally, I’ll create a view and controller helpers to enhance the developer experience.

Approach

I’d like to use React only to render a view or a component and Rails for everything else, keeping our React code scoped.

Starting a New Rails App with Esbuild

rails new app -j esbuild
Read more about this here.

Then, install the latest React:

yarn add react react-dom lodash-es
We could avoid adding lodash-es, but we would have to rewrite a few parts.

Generate a Home Page to Test React Elements

rails g controller home index

As mentioned before, I’d like to keep all of my React code in app/javascript/react

mkdir app/javascript/react

At this point, we have React installed along with Esbuild configured.

Adding a JS Helper Method to mount and render React Components

Create a new file at app/javascript/react/application.js inspired by this source:

import React from "react";
import ReactDOM from "react-dom/client";
import { intersection, keys, assign, omit } from "lodash-es";

const CLASS_ATTRIBUTE_NAME = "data-react-class";
const PROPS_ATTRIBUTE_NAME = "data-react-props";

const ReactComponent = {
  registeredComponents: {},
  componentRoots: {},

  render(node, component) {
    const propsJson = node.getAttribute(PROPS_ATTRIBUTE_NAME);
    const props = propsJson && JSON.parse(propsJson);

    const reactElement = React.createElement(component, props);
    const root = ReactDOM.createRoot(node);

    root.render(reactElement);

    return root;
  },

  registerComponents(components) {
    const collisions = intersection(
      keys(this.registeredComponents),
      keys(components)
    );
    if (collisions.length > 0) {
      console.error(
        `Following components are already registered: ${collisions}`
      );
    }

    assign(this.registeredComponents, omit(components, collisions));
    return true;
  },

  mountComponents() {
    const { registeredComponents } = this;
    const toMount = document.querySelectorAll(`[${CLASS_ATTRIBUTE_NAME}]`);

    for (let i = 0; i < toMount.length; i += 1) {
      const node = toMount[i];
      const className = node.getAttribute(CLASS_ATTRIBUTE_NAME);
      const component = registeredComponents[className];

      if (component) {
        if (node.innerHTML.length === 0) {
          const root = this.render(node, component);

          this.componentRoots = { ...this.componentRoots, [className]: root };
        }
      } else {
        console.error(
          `Can not render a component that has not been registered: ${className}`
        );
      }
    }
  },

  setup(components = {}) {
    if (typeof window.ReactComponent === "undefined") {
      window.ReactComponent = this;
    }

    window.ReactComponent.registerComponents(components);
    window.ReactComponent.mountComponents();
  },
};

export default ReactComponent;

This code provides an interface to register components via a setup method and then mount them.

Dive into the mount part: it will find all elements that contain data-react-class and data-react-props attributes from the DOM. Then, it will create a ReactDOM element and finally render it with its props

Example React Component and View

Create an example React component and call it from the view via the data attributes:

app/javascript/react/components/HelloReact.jsx
import React from "react";

const HelloReact = ({ message }) => (
  <div>
    <h1>{message}</h1>
    <p>React is working!</p>
  </div>
);

export default HelloReact;

app/views/home/index.html.erb
<div data-react-class="HelloReact" data-react-props="<%= {message: "Hello React"}.to_json %>"></div>

Register the Component

Let’s connect the React component with the HTML div using the setup method.

app/javascript/react/index.js
import ReactComponent from "./application"

import HelloReact from "./components/HelloReact"

ReactComponent.setup({HelloReact})

Running the React Code

Run app/javascript/react/index.js in the app default entry point.

app/javascript/application.js
import "./react"

At this point, we’re able to use React in our Rails app 🎉. However, it would be good to have Ruby helpers that allow us to create a <div> with the React component name and the props we want to render. Let’s work on improving the developer experience.

Enhancing Developer Experience with Ruby Helpers

Create a Ruby method to take a component class name and props, returning a div with the necessary information.

app/lib/react_component.rb
class ReactComponent
  include ActionView::Helpers::TagHelper
  include ActionView::Helpers::TextHelper

  attr_accessor :name

  def initialize(name)
    @name = name
  end

  def render(props = {}, options = {})
    tag = options.delete(:tag) || :div
    data = { data: { "react-class" => name, "react-props" => props.to_json } }

    content_tag(tag, nil, options.deep_merge(data))
  end
end

Then, create a view helper at

app/helpers/application_helper.rb
def react_component(component_name, props = {}, options = {})
  ReactComponent.new(component_name).render(props, options)
end

Update the HTML view to use the new helper:

app/views/home/index.html.erb
<%= react_component "HelloReact", message: "Hello React!" %>

🏁 You can stop here, but I’d like to share the ‘screen’ pattern I’ve been using and how it can improve the developer experience.

Sometimes, I replace the entire view with a React component. When this happens, I like to call this component a ‘Screen’ or a similar name to add semantic meaning. For this example, let’s use the name ‘screen,’ but you can choose your own. The key is to be semantic and consistent.

Using the “Screen” Pattern

Create a screen component at:

app/javascript/react/screens/HomeScreen/index.jsx
import React from "react";
import HelloReact from "../../components/HelloReact";

const HomeScreen = () => (
  <div>
    <h1>Home#index</h1>
    <p>Find me in app/views/home/index.html.erb</p>
    <HelloReact message="Hello React!" />
  </div>
);

export default HomeScreen;

Now, register the HomeScreen component:

app/javascript/react/index.js
import ReactComponent from "./application"

import HomeScreen from "./screens/HomeScreen"

ReactComponent.setup({HomeScreen})

Update the view to render the screen:

app/views/home/index.html.erb
<%= react_component "HomeScreen" %>

At this point, HTML views like this tend to contain only a render method. Wouldn’t it be handy to render the screen directly from the controller?
We can use our ReactComponent directly from the controller and avoid creating a view file to render the screen. Let's do it!

Render from the Controller

Extend the Rails controller to render a React component directly:

config/initializers/react_component_renderer.rb
ActionController::Renderers.add :react_component do |component_name, options|
  props = options.fetch(:props, {})
  tag_options = options.fetch(:tag_options, {})
  html = ReactComponent.new(component_name).render(props, tag_options)
  render_options = options.merge(inline: html)
  render(render_options)
end

Create a controller helper:

app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  def render_react(component_name, props: {}, **options)
    render react_component: component_name, props: props, **options
  end
end

Now we can use react_component from the Rails controller like this:

app/controllers/home_controller.rb
class HomeController < ApplicationController
  def index
    render_react "HomeScreen"
  end
end

Finally, we can remove the app/views/home/index.html.erb as the view is now rendered from the controller.


More DX
 
• Add live reloading for a more productive development experience. Here's an article


Pros

 • No extra gem
 • No extra library (except for lodash-es)
 • React only for building user interfaces

Cons

 • No hot module replacement (HMR)


Extra 🌶️

  • You can gain more insights into different approaches, pros, and costs by reading this article from Thoughtbot.
  • Reading this article by Ryan Bigg will clarify a lot and provide you with additional ideas.
  • I took pieces of code and ideas from this plugin and this pull request

Happy Coding!