React in Rails, One Component at a Time

Engineering Insights

October 1, 2025
Erik Cameron
#
Min Read
React in Rails, One Component at a Time

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:

  • Two separate apps and repos, joined only by an API
  • Building React in the Rails asset pipeline
  • React on Rails, an integration from ShakaCode
  • Superglue, an integration from Thoughtbot

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 Pattern

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.

Installation

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

The Controller

We start by creating a base controller class for component wrappers. This class will be responsible for the following shared functionality:

  • Identify a DOM element on which to build the component, by default the controller’s own element
  • Initialize a React root on that element
  • Extract and/or generate the initial component props (the JSON-parsed value of data-props by default; see the code comment below for why we don’t use a Stimulus value here)
  • Render the component indicated by component() on the React root with the initial props
  • Add an event listener to allow other entities on the page to update the component with new full or partial props (see “Two Tips for Reusable UI With Stimulus” for why we don’t use a Stimulus outlet here) 

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:

  • children(): returns an array of child elements for the component (default is null)
  • rootElement(): render the component on a DOM element other than the one with the controller; useful for (say) cases like form fields where your wrapper class needs another element for storing/exposing component state
  • initialProps(): change the way we infer the initial props for the component
  • getPropsFromEvent(): change the way we extract new props from an incoming update event

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:

Complications

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.

Components That Need to Expose State Via the DOM

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”.

Components That Use React Hooks

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.

Testing

Three places you might test such components:

  • View tests: The easiest (and fastest) check is whether the DOM elements in question have the correct data-controller and data-props attributes. This can be done easily in your view tests. If your use of React is limited to cosmetic adornments, this may be sufficient.
  • System tests: Interactive components can be tested in context with system tests. If your component is a select for example, it’s implicitly tested every time Capybara uses one. If your use of React is limited to small functional enhancements, this may be sufficient. 
  • Unit tests: Because these components are reusable, complex usage really calls for good unit testing. Well-maintained component libraries have unit testing, but (for example) our Ant Select integration detailed above introduces enough new functionality in syncing the visible and hidden state that should have coverage, and as components gain functionality, it may not be exposed in usage for a system test. System tests are (probably) the most expensive tests you have, and relying on them to check reusable components is a recipe both to miss functionality, and to duplicate testing of the functionality they do catch (see “In Defense of Unit Tests” for further thoughts).

Miscellany

Are There Performance Issues With Every Component Being a React Root?

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:

  • There may be a better version of this integration that uses, say, portals
  • Those issues may be a symptom that the minimal Stimulus paradigm no longer applies, and that you may want to refactor using more traditional React tooling

I Use View Components and/or I Feel the Word “Component” Is Overloaded

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:

  • In practical terms this is largely a question of where to put files
  • You could package your React components as View Components

Related Insights

See All Articles
Engineering Insights
React in Rails, One Component at a Time

React in Rails, One Component at a Time

It seems like we should be able to drop React components into our views on an ad hoc basis using Stimulus, which is exactly what we will do.
Engineering Insights
A Near Miss: How I Froze My Website by Adding a Generated Column

A Near Miss: How I Froze My Website by Adding a Generated Column

Do avoid freezing your database, don't create a generated column. Add a nullable column, a trigger to update its value, and a job to backfill old data.
Engineering Insights
The Programmer's Obsession: Lessons from Monica Geller's Light Switch

The Programmer's Obsession: Lessons from Monica Geller's Light Switch

If you're wondering if you'd make a good software engineer, ask yourself how much you're like Monica from Friends.
Previous
Next
See All Articles