React components on Rails and Hotwire Turbo Streams
Dynamically add React components in Rails apps via Hotwire Turbo Streams or Stimulus Reflex
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:
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.
- Add
esbuild
and setup your build system - Add esbuild-rails plugin to get nice helpers for discovering Stimulus controllers
- Add
@hotwired/stimulusjs
- Add a
app/controllers/static_controller.rb
with itsindex.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:
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.