Getting started with React 18 on Rails 7 and ESBuild

Extend Ruby on Rails to register a client-side React component from a Ruby helper.

Getting started with React 18 on Rails 7 and ESBuild

Thanks to Stimulus Reflex in 2021/22, and Hotwire Turbo in 2022/23, we've worked on some very snappy web applications that have appeared to users to be world-class mobile apps or desktop software. And yet they are all server-side rendered HTML, with a tiny sprinkle of custom JavaScript on special occasions. It is now mid-2023 and I've navigated a career without writing any React in my Rails apps. Until there was this widget I wanted, and it was only available in React.

I now need to "add some React to my Rails app". How hard could it be?

My interest is using React in its original environment: making wizzy client-side components. For server-side rendering, I'll keep using Rails and Turbo Streams.

However supporting React and Hotwire Turbo requests actually requires a slightly different approach, which I'll cover it in the next article. Please subscribe above to get the next article direct to your inbox.

This article is hugely thanks to Ryan Bigg's article on the topic. I'm lifting many bits and deviating in a few places based on my own preferences and what I've learned. In the next article we deviate even further to support Turbo Streams.

Setting  Rails 7 for ESBuild

I use Jumpstart Pro, which already has esbuild etc setup. So read Ryan's Installing ESBuild section.

In Jumpstart Pro, the esbuild.config.mjs file looks in app/javascript for entrypoints to build & shake those JavaScript files.

ESBuild and React

Let's add React to our JavaScript tooling. As per Ryan's suggestion, I'm also going to bite the bullet and use TypeScript for my React components.

yarn add react react-dom @types/react @types/react-dom typescript

We need to tell TypeScript a few things. Create tsconfig.json in the root of the Rails app:

{
  "compilerOptions": {
    "module": "commonjs",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "jsx": "react",
  }
}

My first React app inside Rails

We'll create a new ESBuild entrypoint app/javascript/react.tsx to capture all our React components.

Here's a tiny ESBuild entrypoint, that also contains a React Hello that will get us started:

import React from "react";
import ReactDOM from "react-dom/client";

const Hello = () => <h1>Hello from React!</h1>;

const root = ReactDOM.createRoot(
  document.getElementById("my-first-react-app") as HTMLElement
);

root.render(<Hello />);

In esbuild.config.mjs, we can add the new entrypoint and some additional watch paths:

const entryPoints = [
  "application.js",
  "administrate.js",
  "react.tsx"
]
const watchDirectories = [
  "./app/javascript/**/*.{js,ts,tsx,jsx}",
  "./app/views/**/*.html.erb",
  "./app/assets/builds/**/*.css", // Wait for cssbundling changes
  "./config/locales/**/*.yml",
]

The react.tsx entrypoint file is bundled, shaken, and built into a single file that we need to add to our layouts, such as app/views/layouts/application.html.erb. We can included it after the existing application.js entrypoint:

<%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %>
<%= javascript_include_tag "react", "data-turbo-track": "reload", defer: true %>

<%= stylesheet_link_tag "application", media: "all", "data-turbo-track": "reload" %>

We've generated the new JavaScript file that contains React and our tiny Hello component. Now to add it to our app.

Throw the following into a Rails template. For example, app/views/static/index.html.erb:

<div id="my-first-react-app"></div>

Restart and reload your Rails app and you'll see "Hello from React!" rendered.

To confirm, this text was added into the DOM on the client browser. After the page loaded, the #my-first-react-app DOM element was discovered by document.getElementById("my-first-react-app") in react.tsx above, and converted into a React component called App.

With this power you could now do anything client-side React-y.

The goal is a react() rails helper

Except, sprinkling bespoke <div id="some-component" /> throughout your Rails templates, and writing matching React components, doesn't feel delightful.

What I'd prefer is a Ruby helper to use inside my Rails templates to load the Hello React component, pass in some properties to the React component, and some attributes to the HTML element.

I'd like to replace my HTML above with the following react() helper calls. I think they're nicer. The first has explicit property passed in, and the latter assumes a default value:

<%= react "Hello", props: {name: "Dr Nic"}, class: "bg-white p-4 border border-gray-200 rounded" %>
  
<%= react("Hello", class: "bg-white p-4 border border-gray-200 rounded") %>

Our first React component with properties

For our Hello component to accept a name string property, and set a default property.

const Hello = ({ name }: { name: string }) => <span>Hello {name}!</span>;

Hello.defaultProps = { name: "World" }

Dynamically mounting

We can remove the nasty #my-first-react-app DOM element from our Rails template.

Instead, let's aim for HTML like:

<div data-react-component="Hello" data-props="{}"></div>

Eventually, our react("Hello") helper will create this HTML. But for now, let's manually add this HTML into our app/views/static/index.html.erb template (or wherever).

Thanks to Ryan, here is some TypeScript to find any data-react-component DOM elements and mount them as React apps.

Replace react.tsx contents with the following:

import React from "react";
import ReactDOM from "react-dom/client";

type Components = Record<string, React.ElementType>;

export default function mount(components: Components): void {
  document.addEventListener("DOMContentLoaded", () => {
    const mountPoints = document.querySelectorAll("[data-react-component]");
    mountPoints.forEach((mountPoint) => {
      const { dataset } = mountPoint as HTMLElement;
      const componentName = dataset.reactComponent;
      if (componentName) {
        const Component = components[componentName];
        if (Component) {
          const props = JSON.parse(dataset.props as string);
          const root = ReactDOM.createRoot(mountPoint);
          root.render(<Component {...props} />);
        } else {
          console.warn(
            "WARNING: No component found for: ",
            dataset.reactComponent,
            components
          );
        }
      }
    });
  });
}

const Hello = ({ name }: { name: string }) => <span>Hello {name}!</span>;

Hello.defaultProps = { name: "World" }

mount({
  Hello,
});

When the DOM is loaded on the client's browser, the DOMContentLoaded event is captured. We then look for any DOM elements with data-react-component attribute, and inject them with a React component by the same name.

If the DOM element also contains a data-props attribute, then convert it to JSON, and pass it through to the React component as its props.

All the other attributes, such as class stay where they are on the parent DOM element. The React component is nested inside.

To test passing properties, we need to escape the JSON inside data-props. Fortunately, we'll only be doing this once.

  <div data-react-component="Hello"
       data-props="{&quot;name&quot;:&quot;Dr Nic&quot;}"
       class="my-4 bg-white p-4 border border-gray-200 rounded"></div>

  <div data-react-component="Hello"
       data-props="{}"
       class="my-4 bg-white p-4 border border-gray-200 rounded"></div>

Looking inside the browser dev console we can see the original parent DOM, and the React Hello contents <span> inside:

Cleaning up with a react() helper method

We now know how to add HTML into our Rails templates, and they are converted into React components.

Next, we can render this awkward HTML via a simple Ruby helper as introduced above.

Create app/helpers/react_helper.rb:

module ReactHelper
  def react(component_name, props: {}, **args)
    content_tag(:div, "", data: {react_component: component_name, props: props}, **args)
  end
end

We can now replace our two Rails template examples above:

<%= react("Hello", props: {name: "Dr Nic"}, class: "my-4 bg-white p-4 border border-gray-200 rounded") %>
<%= react("Hello", class: "my-4 bg-white p-4 border border-gray-200 rounded") %>

And we see our two Hello components beautifully rendered like before.

In Ryan's article he advocated for using View Components because "we use view components over partials because Ruby code belongs in Ruby files".

I'm not yet sure the extra layering of View Components is necessary.

Our react() helper above is Ruby code; and we can continue to use Rails templates and partials if we want to. If you do want to use View Components, that's great. You can keep using the react() helper.

Moving React components into own folder

Right now, our Hello React component is defined inside our ESBuild react.tsx entrypoint file. This won't scale. We need a pattern for where to put our React components.

Create a new folder app/javascript/react/Hello for our Hello component, and create the code file app/javascript/react/Hello/index.tsx:

import React from "react";

const Hello = ({ name }: { name: string }) => <span>Hello {name}!</span>;

Hello.defaultProps = { name: "World" }

export default Hello;

Back in our react.tsx entrypoint file, we can delete the Hello component, and instead import it:

import Hello from "./react/Hello";

mount({
  Hello,
});

Future React components now have an obvious pattern of where to put them: inside app/javascript/react/<NAME>/index.tsx.

What was the "widget I wanted"?

Why did I start using React? I followed along the free course on Framer Motion at https://buildui.com/courses/framer-motion-recipes/multistep-wizard. All fun and laughs, except it is entirely a React component library.

I completed the tutorial and at the end I had this lovely multi-step wizard widget, and many hopes and dreams of using Framer Motion in future. Fingers crossed it all works out for me. I'll keep updating this post as I learn more.

Dynamically adding any React component

In the example above, we explicitly imported the Hello component. In the next article we'll make this more dynamic.

Support for Hotwire Turbo

Central to nearly all my Ruby on Rails applications is Hotwire Turbo and Stimulus JS. Although I want to sprinkle fancy React components into my HTML, I still want to dynamically produce much HTML on the server side, and deliver it via Turbo Streams.

Alas, the solution in this article does not yet support Turbo Streams.

The entrypoint code in react.tsx above only injects React components once: when the page loads. It does not keep watching for any new <div data-react-component> DOM elements that appear via Turbo Streams.

So we need a new approach.

In the next article, we'll replace react.tsx with a Stimulus controller that will take over the reponsibility of watching for DOM elements that we want to upgrade into React components.

Please subscribe to our newsletter above and we'll send you the next article shortly.

Mastodon