React components on Rails and Hotwire Turbo Streams

Dynamically add React components in Rails apps via Hotwire Turbo Streams or Stimulus Reflex

React components on Rails and Hotwire Turbo Streams
Server-side rendering of HTML via Turbo Streams, which become React components on the client side.

I want to lightly sprinkle some React components into my Ruby on Rails apps. Importantly, some of the components will be added dynamically via Hotwire Turbo Streams – either as a response to user actions, or broadcast to many browsers in parallel. In Part 1, I added support for React to my Rails app, but it failed when I started sending Turbo Stream updates.

In this Part 2 article, we will use StimulusJS to inject React components into client browsers whenever new HTML arrives: new page loads, Turbo Stream updates, Stimulus Reflex updates, and more.

We will also dynamically register any new React components you add into your codebase, without explicitly importing them.

We'll also update the react(name, props: {}) helper. I liked it so let's keep it. Our Rails template syntax for adding React components will look like:

  <%= react("Hello") %>
  <%= react("Hello", props: {name: "Dr Nic"}) %>

This article is a standalone tutorial, and does not assume you followed along with Part 1. We'll build up our React-via-StimulusJS solution from scratch.

The files created during the tutorial are available in the gist:

In Rails apps using StimulusJS, load React components found at app/javascript/react/*/index.tsx, and make them available via Ruby helper react(name)
In Rails apps using StimulusJS, load React components found at app/javascript/react/*/index.tsx, and make them available via Ruby helper react(name) - Hello.tsx

Versions of libraries

At the time of writing, I'm using React 18, Rails 7.0, esbuild-rails 1.0.7, @hotwired/stimulus 3.0. Probably the most fragile parts of the article will be calling React APIs to mount and unmount components. They changed for React 18, so they might change again in future.

Setting up

In lieu of reading Part 1, here's the steps to setup for this tutorial.

Some steps you've probably already done in your application to setup esbuild and hotwire/stimulusjs.

  1. Add esbuild and setup your build system
  2. Add esbuild-rails plugin to get nice helpers for discovering Stimulus controllers
  3. Add @hotwired/stimulusjs
  4. Add a app/controllers/static_controller.rb with its index.html.erb action mounted at / root path. We will work here.

If you're using Jumpstart Pro, this is all done for you. If not, find a handy-dandy tutorial for setting it up.

Install react + typescript libraries. Why typescript? Life can be better when you're using typescript for your React components.

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

Add a typescript config file tsconfig.json:

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

Create a Hello React component used for testing in this tutorial.

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;

How do we import this Hello component, and inject it into our DOM? Good question. That's the topic of this article. Stay with me.

Dynamically discovering DOM

In Part 1, we only performed a one-time discovery to inject React components. In our HTML we used the following DOM, and it injected the Hello React component inside.

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

Our Part 1 react.tsx esbuild entrypoint code did a one-time discovery of all DOM elements with data-react-component attribute, and performed the React mounting.

But, if any new DOM elements were added, such as via Turbo Streams responses or broadcasts, our react.tsx code would not perform the React component mounting.

My applications already have StimulusJS, which does a spectacular job of monitoring the DOM tree for the arrival or removal of DOM elements containing the data-controller attribute.

Therefore, our goal will be to use a shared StimulusJS controller to dynamically discover or remove React components.

Now in Part 2, the data-react-component="Hello" example above will change to:

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

Soon we'll create the react_controller.js stimulusjs controller that can mount any React component, including our Hello example.

Ruby helper

Create a file app/helpers/react_helper.rb Ruby helper react() to produce this lovely HTML snippet, in a

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

In our app/views/static/index.html.erb (or wherever you want to play), let's get ready to render our Hello component:

<h1 class="mb-2">Welcome to React on Rails</h1>

<div id="hellos" class="my-4 bg-white p-4 border border-gray-200 rounded">
  <%= react("Hello") %>
  <%= react("Hello", props: {name: "Dr Nic"}) %>
</div>
<%= button_to "Add", "#", class: "btn btn-primary" %>

Right now, we don't see "Hello World" or "Hello Dr Nic".

We will see them soon.

Note the id="hellos" in the parent DOM element, and the "Add" button. We'll use them later to dynamically append new Hello React components into the page. Fingers crossed.

StimulusJS controller for mounting React components

Create a Stimulus controller app/javascript/controllers/react_controller.js (this path works for Jumpstart Pro apps; place your Stimulus controller where it will be discovered in your app)

Initially, we'll hard-code support for our Hello component. We add it to a modules object {"Hello": Hello} so we can look it up later.

import { Controller } from "@hotwired/stimulus"
import React from "react"
import ReactDOM from "react-dom/client"

import Hello from "../react/hello"
const modules = { Hello }

export default class extends Controller {
  static values = {
    component: String,
    props: Object
  }

  connect() {
    const module = modules[this.componentValue]
    if (module) {
      this.root = ReactDOM.createRoot(this.element)
      this.root.render(
        React.createElement(module, this.propsValue)
      )
    } else {
      console.error(`Could not find module ${this.componentValue}`)
    }
  }

  disconnect() {
    this.root.unmount()
  }
}

Note, this is using React 18 API for mounting and unmount. It is handy we can store the React root object in our Stimulus controller so we can find it again later and unmount it.

And that's it. If we restart the app and refresh the page we should now see our two static React components come to life: "Hello World", and "Hello Dr Nic":

Compared to Part 1

In the Part 1 article we added a new esbuild entrypoint react.tsx and then added it into the application.html.erb layout. In this Part 2 article we do not need another entrypoint. If our application was already loading up Stimulus controllers, then we're good to go after adding the react controller above.

React and Turbo Streams

The StimulusJS controller above should "just work" if new DOM is added via Turbo Streams. Stimulus constantly watches the DOM for changes, and attaches our react controller if a new <div data-controller="react"> appears. The controlller's connect() method then decides which React component to mount, adds the props, and away we go.

For a demo, in the Rails controller, add a new action, say send_react_component, that will return a Turbo Stream:

class StaticController < ApplicationController
  def index
  end

  def send_react_component
  end
end

Add the action to the Rails routes:

Rails.application.routes.draw do
  scope controller: :static do
    post :send_react_component
  end
  ...
end

Create a turbo_stream.erb action response app/views/static/send_react_component.turbo_stream.erb:

<%= turbo_stream.append "hellos", react("Hello") %>

Finally, update the "Add" button to use the new POST action:

<%= button_to "Add", send_react_component_path, class: "btn btn-primary" %>

Each time we click the "Add" button, a new "Hello World" appears in the DOM:

This demonstrates that our React component integration now works with Turbo Streams, or any other technique for dynamically adding HTML elements to the browser.

Random names

You've gotten this far. Let's add some random names with the faker gem.

Whilst people generally use faker for testing only, let's add it to the whole app:

bundle add faker

Update app/views/static/send_react_component.turbo_stream.erb to use it to produce random first names:

<%= turbo_stream.append "hellos",
      react("Hello", props: {name: Faker::Name.first_name}) %>

Restart the app. Click Add button and enjoy the server-side generated randomness.

Dynamically discovering React components

In our react component above we explicitly imported the Hello component from its parent directory at app/javascript/react/Hello.

Thanks to Marco Roth for helping me make this code dynamic.

In the react_controller.js replace the following two lines:

import Hello from "../react/hello"
const modules = { Hello }

With:

import modulePaths from "../react/**/index.tsx"
const modules = {}

// Snazzy code written by Marco Roth on discord
const capitalize = string => string.charAt(0).toUpperCase() + string.slice(1)
const camelize = string => string.replace(/(?:[_-])([a-z0-9])/g, (_, char) => char.toUpperCase())

modulePaths.forEach((file) => {
  const name = file.filename.split("/").reverse()[1]
  const identifier = capitalize(camelize(name))

  if (!modules.hasOwnProperty(identifier)) {
    modules[identifier] = file.module.default
  }
})

All the files

All the files we created or edited above are available in a Gist:

In Rails apps using StimulusJS, load React components found at app/javascript/react/*/index.tsx, and make them available via Ruby helper react(name)
In Rails apps using StimulusJS, load React components found at app/javascript/react/*/index.tsx, and make them available via Ruby helper react(name) - Hello.tsx

Subscribe to the newsletter

Please subscribe to our newsletter, and follow us on YouTube, for more fabulous content on modern web programming, Ruby on Rails, Jumpstart Pro, and more.

Mastodon