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:
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:
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:
react-rails
(winner?)react_on_rails
(seemingly made from the same people asreact-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="{"title":"It works!"}" 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 🔥.
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!
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