Rails + Stimulus + React
In this post I am going to demonstrate how to use prebuilt React components in an existing Rails and Stimulus project. Many of the traditional jQuery based UI control libraries, like select2 and various date pickers, have gone stagnant and are no longer actively developed. There is no question that the most actively developed UI libraries are for React, Vue, and other modern front end web frameworks. While React and friends have a place, a huge number of web apps are not served by going full SPA, with their added complexity. By utilizing React for common UI controls, and retaining the vast majority of our other views as traditional Rails partials, we can leverage the React community while retaining the simplicity of server rendered HTML for the bulk of our app.
Here we will be proxying a native multi-select with react-select, which will be embedded in a Stimulus controller that handles change events. Our app will be a simple list of filterable foods, which fall into 3 different categories: fruit, vegetable, and dairy. Even though we only have a few items we’ll be doing our filtering on the server to replicate a typical application.
The full example can be found here: https://github.com/dbridges/stimulus-and-react
Stimulus
We start with a fairly standard Stimulus setup. Our Rails home controller renders the multi-select and the list of filtered foods for the initial value of “fruits”. These elements are wrapped in a food
Stimulus controller which looks for a change event on the multi-select, then fetches the updated lists of foods based on the current selection, and finally replaces the food list HTML with the response HTML.
React
We’ll be using react-select as a proxy for our native select. This allows us to continue using our original ruby helpers for generating the select and its options — we’ll translate the generated HTML into props for our react-select. We’ll also add a little Rails helper method to wrap our native select in a new Stimulus controller to manage its changes.
Lets add React to our project:
bundle exec rails webpacker:install:react
We’ll also add the `react-select` library:
yarn add react-select
On the Rails side we first introduce a react_select
view helper that wraps its contents in an appropriately named Stimulus controller directive:
def react_select(**opts)
tag.div(**opts.deep_merge(
data: { controller: "react-select" })) do
yield
end
end
When rendering the select tag, we wrap it in our new helper:
<%= react_select do %>
<%= select_tag "kinds",
options_for_select(Food.kinds.keys, "fruit"),
multiple: true,
data: { action: "change->food#filter" } %>
<% end %>
We need to create a new Stimulus controller to manage the react-select. This is a bit more complicated so we’ll go through it piece by piece.
Our initialize
method first checks to see if this controller has already been initialized. This is required to handle Turbolinks’ behavior of loading a cached version of the page when navigating back. When this cached version is loaded Stimulus tries to re-initialize all of the controllers again.
export default class extends Controller {
initialize() {
if (!this.data.get("initialized")) {
this.initReactSelect();
}
} ...
}
If this is a fresh initialization initReactSelect
is called. First we grab our embedded select and hide it.
initReactSelect() {
const select = this.element.querySelector("select"); select.style.display = "none"; ...
}
We then assemble the props for react-select by reading attributes from the embedded native select:
initReactSelect() {
... const options = [...select.options]; const defaultValue = [...select.selectedOptions] const isMulti = select.getAttribute("multiple") != null; ...
}
The onChange
handler is the most complicated:
initReactSelect() {
... const onChange = value => {
if (!Array.isArray(value)) {
select.value = value.value;
} else {
const selected = value.map(opt => opt.value);
for (const opt of select.options) {
if (selected.includes(opt.value)) {
opt.setAttribute("selected", "selected");
} else {
opt.removeAttribute("selected");
}
}
} select.dispatchEvent(new Event("change"));
}; ...
}
First we test if the value coming from react-select is an array or not. If it is a single select the value is just passed directly back and we simply assign it to our select and dispatch a change event. If there are multiple values we need to iterate through the options of our embedded native select and update their selected
attribute before dispatching a change event.
Next we create a div
element and mount a react-select to it:
initReactSelect() {
... const reactSelect = document.createElement("div");
select.parentNode.insertBefore(reactSelect, select);
ReactDOM.render(
<Select
options={options}
defaultValue={defaultValue}
onChange={onChange}
isMulti={isMulti}
/>,
reactSelect
); ...
}
Finally we assign a data attribute to our controller so we don’t accidentally recreate the react-select on subsequent initializations.
initReactSelect() {
... this.data.set("initialized", true);
}
And that is it! We didn’t even have to modify our original Stimulus `food` controller! For any other selects in our app we simply need to wrap them with our react_select
helper to transform them into nice modern multi-selects.
I’ve found utilizing React as a replacement for common UI controls to be very powerful. It lets you avoid complex state management on the front end: no redux, no react router, no context, just some simple props. By isolating our React usage to common controls we can keep our application specific views and partials in Rails, which greatly simplifies the structure of the app and also allows us to leverage all of the nice Rails view helpers. This makes apps faster and easier to build, but still lets us bring in a little bit of React when we need it.