How to setup react-rails with esbuild

We need calendars for scheduling home painting projects for some internal tools we're building at Craftwork.

Before any code was written, the team started with Notion calendars:

Notion calendaring solution

That worked well, but we wanted to integrate calendars into our Rails application deeply. For a while, I've had my eye on the simple_calendar gem. Which works well for setting up basic calendar views! This one uses CSS from the Tailwind UI catalog:

simple_calendar gem view

Again, great for static stuff you want to show on a calendar, but eventually, we wanted to have many multi-day events that can be expanded or contracted to different dates directly on the calendar. A more interactive experience our team has grown to like from the Notion solution.

This was the time to add React into our Rails. That'll be easy, I thought. Just a couple hours of work, I thought. PSSSSSH. Naw, dog -- the gods don't want you putting React in your Rails, for some reason. Or, at least they don't want you to without their build tools!

Bard sent me a marketing email a couple days ago with an example use case of "Comparison" so I asked Bard to compare different libraries for adding React to Rails:

Bard response

  • react-rails (winner?)
  • react_on_rails (seemingly made from the same people as react-rails? Why are these different things? 🤷‍♂️)
  • htm (naw, the team wants to use jsx)
  • webpacker (wait, that's not a react thing at all, that's a build tool that I def don't want to use because I'm allergic to configuration)

Okay cool, let's dig into react-rails.

I scan the README hoping for a few bundle add this and yarn add that, but we're instead met with two options: Get started with Shakapacker or Use with Asset Pipeline. We already have a bundler: esbuild, I don't want to switch just to use react. So I try the Asset Pipeline solution first, which didn't work. After trying several things (vite, importmaps, stimulus wrapper), I found a couple of issues on the repo:

https://github.com/reactjs/react-rails/issues/1149

https://github.com/reactjs/react-rails/issues/1134

This dev, IsmailM, politely asks for documentation for setting up with ESBuild, but the maintainer pushes back and asks why not use webpack with its new support for esbuild. (because... we don't want to touch webpack with a 10-foot pole).

I don't want to use webpack, or webpacker, or shakapacker, or any complex configuration for a simple build process. We only need a small bit of TypeScript to play nicely with React.

multiplegeorges commented with a nice solution as did cionescu here.

multiplegeorges and cionescu's solutions got us close, but didn't work 100%, which is why I'm writing this down:

One of the problems with esbuild is it doesn't come with glob imports out of the box, so we need to add a plugin for that called esbuild-plugin-import-glob. We'll also install a bunch of npm packages that react-rails tells us to:

yarn add esbuild-plugin-import-glob

And add the plugin:

// esbuild.config.mjs (or .js or whatever)
// other imports
import ImportGlobPlugin from "esbuild-plugin-import-glob";

const config = {
  absWorkingDir: path.join(process.cwd(), "app/javascript"),
  bundle: true,
  entryPoints: entryPoints,
  minify: process.env.RAILS_ENV == "production",
  outdir: path.join(process.cwd(), "app/assets/builds"),
  plugins: [rails(), ImportGlobPlugin.default()],
  sourcemap: process.env.RAILS_ENV != "production"
}
// there's other stuff in here, but just be sure to add ImportGlobPlugin to your plugin array

Now you should be able to use import globs in your JavaScript like:

import * as Components from "./components/**/*.{js,ts,jsx,tsx}"

Which is exactly what we need to do in our application.js entry point:

// app/javascript/application.js
// other stuff...

import React from "react";
import * as Components from "./components/**/*.{js,ts,tsx,jsx}";

const componentsContext = {}

for (const [_, components] of Object.entries(Components)) {
  components.forEach(({ module }) => {
    Object.entries(module).forEach(([name, component]) => {
      componentsContext[name] = component
    })
  });
}

const ReactRailsUJS = require("react_ujs")

ReactRailsUJS.getConstructor = (name) => componentsContext[name]

ReactRailsUJS.handleEvent('turbo:load', ReactRailsUJS.handleMount, false);
ReactRailsUJS.handleEvent('turbo:frame-load', ReactRailsUJS.handleMount, false);
ReactRailsUJS.handleEvent('turbo:before-render', ReactRailsUJS.handleUnmount, false);

Next we'll install the gem and run it's install command:

bundle add react-rails
rails g react:install

The install command creates a bunch of files in the old-school app/assets/javascripts directory instead of the 😎 cool new app/javascript directory. So I rename the server_rendering.js file to move it and then delete the rest:

mv app/assets/javascripts/server_rendering.js app/javascript/
rm -rf  app/assets/javascripts/

While I was debugging, running node esbuild.config.mjs a few times helped me debug and get things at least built before starting the server and testing things out.

Next, I created a new .jsx file in app/javascript/components/post.jsx to test:

import React from 'react';

export const Post = ({ title }) => {
  return (
    <>
      <h1 className="text-2xl font-bold">Hey, <span className="underline">{title}</span></h1>
    </>
  );
}

Then, in one of the ERB templates, add this line to render the mount point:

React component: <%= react_component("Post", {title: "It works!"}) %>

That spits out a tag with some data-* attributes where the component is mounted into:

<div data-react-class="Post" data-react-props="{&quot;title&quot;:&quot;It works!&quot;}" data-react-cache-id="Post-0"><h1 class="text-2xl font-bold">Hey, <span class="underline">It works!</span></h1></div>

Okay, we've got react rendering client side now which is cool. I paired with my boss, the boss, Mike Bifulco for a few hours and together, we got FullCalendar full sending up in our Rails app with fully working drag and drop and editable events 🔥.

React rendered FullCalendar

The next big hurdle is using react.email for rendering our email templates. We want to use react.email for several reasons. (1) the team has experience with using it to generate nice looking email (2) the generated output supports many email clients nicely (3) we hate writing table HTML and would much rather work with modern dev tool chains.

Once we got react-email rendering client side, I wanted to try getting it working server side. Out of the box, react-email has support for server rendering... if you're using one of their preferred bundlers: shakapacker or the asset pipeline. Otherwise, there's a bit of gymnastics involved.

In the Server Rendering section of the react-rails readme you'll see that adding prerender: true in the third argument to react_component should render the component server side. I though, cool, I'll try creating a mailer template that renders a react_component built from the react.email tools:

<!-- app/views/project_mailer/prep.html.erb -->
<%= react_component("ProjectPrepEmail", { project: @project }, { pretender: true }) %>

Theoretically, that should find the ProjectPrepEmail component in my component tree and render it's HTML, passing the project to the props.

But I was getting this error:

Caused by ExecJS::ProgramError: TypeError: Cannot read properties of undefined (reading 'serverRender')

Recall that we changed the entry point for application js? The server_rendering.js file that comes with react-rails wont work with esbuild, so we had to modify it to use our custom glob import thing like this:

// app/javascript/server_rendering.js
import React from "react";
import * as Components from "./components/**/*.{js,ts,tsx,jsx}";

const componentsContext = {}

for (const [_, components] of Object.entries(Components)) {
  components.forEach(({ module }) => {
    Object.entries(module).forEach(([name, component]) => {
      componentsContext[name] = component
    })
  });
}

const ReactRailsUJS = require("react_ujs")

ReactRailsUJS.getConstructor = (name) => componentsContext[name]

I left off the event handling stuff because it doesn't matter on the server side, and it doesn't seem to work on the server side either.

Once that's updated, I was able to yarn build to get the new app/assets/builds/server_rendering.js bundle which is used by react-rails to render server side.

Check this out!

Server rendered email component

I did also play around with setting up a custom container for working with esbuild, but it doesn't seem like it's required:

# frozen_string_literal: true
module React
  module ServerRendering
    # Get a compiled file from esbuild's output path
    class EsbuildBundleContainer
      def self.compatible?
        true # Since we're using esbuild for craftwork
      end

      def find_asset(filename)
        asset_path = ::Rails.root.join("app/assets/builds/#{filename}")
        File.read(asset_path)
      end
    end
  end
end

# To render React components in production, precompile the server rendering manifest:
Rails.application.config.assets.precompile += ["server_rendering.js"]
Rails.application.config.react.server_renderer_extensions = ["jsx", "js", "tsx", "ts"]

Rails.application.configure do
  config.react.camelize_props = true # default false

  React::ServerRendering::BundleRenderer.asset_container_class = React::ServerRendering::EsbuildBundleContainer
  config.react.server_renderer = React::ServerRendering::BundleRenderer
end