It feels like it should be easy to integrate React components into Rails on an ad hoc basis—they’re just JS, and Rails definitely knows how to serve JS—but anecdotally, heavier solutions seem to be the norm. For example, Thoughtbot’s April 2024 guide to integrating React with Rails 7 covers four approaches:
While all of these solutions are doable, they require new abstractions, and possibly new code practices or deployment tooling. A significant amount of this appears to be intended for single page applications (server side rendering, for example). The simplest to code is probably React on Rails, and the documentation is substantial. None of the four use Stimulus.
We on the other hand want a simple pattern for rendering React components that uses the Stimulus happy path. The idea is to sprinkle in some advanced UI, not to write a whole new front end. We have no interest in rendering React components server-side, because (let’s say) Rails and Stimulus was the right choice for our hypothetical application; we don’t have problems with SEO or initial page loads, and we have no interest in complicating our deployments, managing two repos, or even in writing JSX. The more these things look like regular Rails partials, the better.
In the real-life case that inspired our integration, our front end was built in Rails and Hotwire, but we were working off mockups made using the popular React component library Ant Design. Several months in, it became clear it would be a massive win to more or less just bring the mockups to life, as long as we could do it progressively—given our timeline, there was no appetite for a major refactor. Sometimes you can’t start from scratch.
The short version is that we use a Stimulus controller to create a React root on a given DOM element. That controller manages the React component state, and (re)renders it as necessary. Views call components as partials, passing in props as locals. Because the components are invoked by calling createElement, no JSX is required. (You can still build components using JSX if you like of course—Ant Design is written in JSX, for example.) In React terms, the Stimulus controllers function like what used to be called “container components,” i.e., a component that renders no visual UI of its own, but manages the state of a visible child component.
We were of course not the only team to wind up here. Gabriel Quaresma at Codeminer 42 gives a nice writeup of their approach, which was published in December 2024, and so would have missed the Thoughtbot roundup from earlier that year; this had me wondering if this approach will become more common as Stimulus gains further adoption. I also found older examples of the same basic idea from Dan Bridges (2020) and Clay Murray (2019).
Our approach seems logical insofar as you’re committed to Stimulus. I suspect the popularity of the heavier solutions are due to the advanced requirements of single-page applications, which is not what you use Stimulus for. So, if you like your mostly static front end, but just want to give it a makeover, read on.
The integration uses the react and react-dom packages, which you should install normally, using your package manager, or by adding them to package.json. Our examples below also make use of antd, the Ant Design component library.
We start by creating a base controller class for component wrappers. This class will be responsible for the following shared functionality:
Subclasses will then be responsible for identifying the component to render, customizing the boilerplate behavior, and maintaining state as needed.
The base class looks something like this:
// app/javascript/controllers/components/component_controller.js
import { Controller } from '@hotwired/stimulus'
import { createElement } from 'react'
import { createRoot } from 'react-dom/client'
export class ComponentController extends Controller {
connect() {
// create a React root on the indicated element
this.reactRoot = createRoot(this.rootElement)
// initialize component props
this.props = this.initialProps
// initial render
this.render(this.props)
// listen for new props
this.element.addEventListener('update', this.update.bind(this))
}
disconnect() {
this.reactRoot.unmount()
this.element.removeEventListener('update', this.update.bind(this))
}
get component() {
// subclasses will return a React component out of this method
throw new Error('you must specify a component to render')
}
get children() {
// subclasses may want to populate this
return null
}
get rootElement() {
// default to the element the controller is mounted on
return this.element
}
get initialProps() {
// we avoid using a Stimulus value here because we will ultimately call
// this in a subclass with a different name, which would have to be
// encoded in the data-*-props-value attribute if we did it that way;
// this lets all subclasses use `data-props`, and allows us to separate
// component props from additional info to pass to the wrapper class (for
// which we would use regular Stimulus values)
return JSON.parse(this.data.get('props'))
}
update(event) {
// extract the new partial props from the incoming event and rerender
this.updateProps(this.getPropsFromEvent(event))
}
private updateProps(partialProps) {
// merge the incoming props with the existing ones
this.props = { ...this.props, ...partialProps }
// render a new component on the root element
this.reactRoot.render(createElement(this.component, props, this.children))
}
private getPropsFromEvent(event) {
// extract props from the incoming event
return event.detail
}
}
At minimum, descendants of this class must implement the component() getter, returning the React component they’d like rendered. (This also means you never instantiate the base class directly.) They may also override the following methods to customize the behavior of the integration:
A simple example implementing Ant Design’s InputNumber might look like so:
// app/javascript/components/input_number_controller.js
import { InputNumber } from 'antd'
import { ComponentController } from './component_controller'
export class InputNumberController extends ComponentController {
get component() {
return InputNumber
}
}
Remember to rebuild the Stimulus manifest (bin/rails stimulus:manifest:update or equivalent) when you add a new controller—if a controller referenced in a data-controller attribute isn’t present in the manifest, nothing happens, literally.
Now we create the partial to call it:
<!-- app/views/components/_input_number.html.erb -->
<%# locals: (min: 0, max: 100, value:, name:) %>
<%= content_tag(
:div,
data: {
controller: "input-number", props: {max:, min:, value:, name:}
}
) %>
We can then call this partial in a form, using the name prop to set the form field:
<!-- app/views/widgets/_form.html.erb -->
...
<%= form.label :score, t("score") %>
<%= render "components/input_number", min: 1, max: 10, value: @widget.score, name: "widget[score]" %>
...
Our component, rendered:
Many components are more or less that easy, but not all. Here are a couple of wrinkles we ran into, and how we solved them.
The Ant Design InputNumber renders a form field that integrates nicely, but we found that, under certain circumstances, the Select component does not render an HTML input of any kind, making it invisible to normal forms. The solution is to render a hidden field in addition to the element with the component, and use a method in the Stimulus wrapper to sync the two when the component changes value.
In the partial, we attach the select controller to a div with two children. The first is the hidden form input; the second is a div that will be the root element for the component. We identify them as targets for SelectController:
<%# locals: (name:, options:, value:, form:) %>
<!-- app/views/components/_select.html.erb -->
<%= content_tag :div, data: {controller: "select", props: {options:, placeholder:} do %>
<%= form.hidden_field name, value:, data: { select_target: "input" } %>
<%= content_tag :div, data: { select_target: "component" } %>
<% end %>
Then in the controller, we pass in an onChange method with access to those targets:
import { Select } from 'antd'
import { ComponentController } from './component_controller'
export class SelectController extends ComponentController {
static targets = ['component', 'input']
get rootElement() { return this.componentTarget }
get component() { return Select }
get initialProps() {
const props = this.propsFromData
return { ...props, onChange: this.onChange.bind(this) }
}
onChange(value) {
// update the hidden input
this.inputTarget.value = value || ''
// rerender the select with the new value
this.updateProps({ value })
}
}
This technique can be useful integrating any tool that stores state as a runtime JS object with Stimulus, where “state lives as attributes in the DOM”.
To make use of React hooks, you can create an intermediary component that receives the value and update function. Here’s an example using the Antd Radio component:
import { Radio } from 'antd'
import { createElement, useEffect, useState } from 'react'
import { ComponentController } from './component_controller'
const UpdateableRadioGroup = (props) => {
const [value, setValue] = useState(props.value)
useEffect(() => { setValue(props.value) }, [props.value])
const onChange = (e) => { setValue(e.target.value) }
return createElement(Radio.Group, { ...props, value, onChange })
}
export class RadioController extends ComponentController {
get component() { return UpdateableRadioGroup }
}
The intermediate component is named descriptively (UpdateableRadioGroup) but it is effectively private to RadioController, which is what callers actually use.
Three places you might test such components:
We leaned on this pattern pretty hard and the answer was no. For cases that fit the Stimulus paradigm of “enhanc[ing] … the HTML you already have,” I doubt it will be a problem. That said:
I hear you. (And feel the same about “controller.”) We introduced view components into our project as well, and it became a little confusing. That said: