Usable multi-selects in Rails forms
# 12 Sep 2022 by SeanLook. I use Rails for side projects because I’m lazy. But not so lazy that I’m willing to put up with really really bad multi-selects.
This is a quick and not very dirty way to get clean and largely seamless multi-selects for Rails forms that still send data over REST, without changing the way anything works. It uses React, Stimulus, and Rails ActionController.
I’m assuming a Rails 7+ setup with esbuild
as the JS bundler. This will also work with Webpack/er, and with importmap,
assuming you use a JSX shim. I’ve used this as far back as Rails 6 too, with minor changes for API change.
Adding a multi-select React component
This component is going to be mounted on a single DOM node in our form, and provide our multi-select behavior. It is also going to write out our form data on every render, which is how we get our data back out of JS-land and into the Rails form.
First, we need to install React, ReactDOM, and react-select
:
% yarn add react react-dom react-select
Then, add a basic component:
// app/javascript/components/multi_select.jsx
import React, { useState } from 'react'
import Select from 'react-select'
export const RailsFormInputMultiSelect = ({
name, // Rails form input field name, like "kit[producers][]"
collection, // JSON, array of objects like { value: ID, label: NAME }
currentValue, // JSON, array of scalars whose values must be a subset of the collection values
}) => {
const options = JSON.parse(collection)
const [ values, setValues ] = useState(JSON.parse(currentValue))
const updateValues = (selections, _selectionEvent) => setValues(selections.map(({ value }) => value))
const selectValue = options.filter(({ value }) => values.includes(value))
return (
<>
{
value.map((v, index) => (
<input key={ index } type="hidden" name={ name } value={ v } readOnly />
))
}
<Select
value={ selectValue }
onChange={ updateValues }
options={ options }
isMulti
closeMenuOnSelect={ false }
/>
</>
)
}
This component accepts 3 props:
- a
name
, which corresponds to thename
attribute Rails would have given this input if we usedf.select :attr_name
in a standard Rails model. This will look like"my_model[association_ids][]"
(which indicates that Rails is expecting an array of scalars representing the IDs in ahas_and_belongs_to_many
association). Easiest way to figure out what to put here is to actually render out thef.select :attr_name
and inspect thename
attr on the generated<input />
and steal it - a
collection
prop, which is a JSON array of objects representing each option in the select. They need to be structured like{ value: ID, label: "Friendly label representing the ID" }
, where the ID is probably your database ID. In reality this can be any scalar you want, so long as your controller can convert that into something that’s useful in the backend - a
currentValue
prop, which is a JSON array of scalars, which should be a subset of thevalue
fields in yourcollection
prop. For example, the currently present IDs onmy_model.association_ids
Don’t worry about how we’re going to get these props into the component from our Rails backend yet.
The real work being done here (other than a sane and usable multi-select React component) is the <input name={ name } type="hidden"
value={ values.join(',') } readOnly />
being written out. Every time that the values
state is updated, this input
node will get rerendered with the most up-to-date selection. And, because it is using the name we passed in (having
stolen it from a standard Rails input render), when the Rails form gets submitted this is going to get picked up and
passed along to the backend as if it had been rendered by f.select ...
.
Mounting the React component without going batshit
We’re using this one component to add a small bit of behavior to an existing Rails view, so we definitely don’t want a massive new JS frontend. Which… most React applications want to be.
Instead, we’re going to use Stimulus to create a small controller that will allow us to tactically mount and render this component when we need it in a form.
I’m assuming you have Stimulus installed, since it’s a Rails application. If not, hotwire.dev and the Rails docs have you covered.
Add a Stimulus controller something like this:
// app/javascript/controllers/rails_input_multi_select_controller.js
import { Controller } from "@hotwired/stimulus"
import React from 'react'
import { createRoot } from 'react-dom/client'
import { RailsFormInputMultiSelect } from 'components/rails_form_input_multi_select'
export default class RailsFormInputMultiSelectController extends Controller {
connect() {
const { name, collection, currentValue } = this.element.dataset
import('components/rails_form_input_multi_select').then(module => {
const RailsFormInputMultiSelect = module.RailsFormInputMultiSelect
const root = createRoot(this.element)
root.render(
<RailsFormInputMultiSelect
name={ name }
collection={ collection }
currentValue={ currentValue }
/>
)
})
}
}
and register it like this:
// app/javascript/controllers/index.js
import { application } from "./application"
import RailsFormInputMultiSelectController from './rails_form_input_multi_select_controller'
application.register("multi_select", RailsFormInputMultiSelectController)
Or, use the bin/rails stimulus:manifest:update
command, and adjust the next steps accordingly (since it’ll register
the controller against an auto-generated name by default).
With the controller in place, we can now mount this input wherever we need to very easily.
Using the multi-select in anger
In a form, it’s now hella easy to get a decent multi-select. Assuming a model Kit
with a has-and-belongs-to-many
association to Material
, it will look something like this:
- form_for(@kit) do |f|
= f.text_field :name
# etc
- material_options = Material.pluck(:id, :name).map {|id, name| { value: id, label: name }}
= f.label :my_association
%div{ data: { controller: "multi_select", name: "kit[materials][]", collection: material_options.to_json, current_value: @kit.material_ids.to_json } }
I’ve even got this extracted out to a partial to make it cleaner:
// app/views/application/_multi_select.html.haml
.py-1{ data: { controller: "multi_select", name:, collection: collection.to_json, current_value: current_value.to_json } }
and my form becomes this
- material_options = Material.pluck(:id, :name).map {|id, name| { value: id, label: name }}
= f.label :my_association
render('multi_select', name: "kit[material_ids][]", collection: material_options, current_value: @kit.material_ids)
It hides the JSONification away in one place, and I als o get to use some nice Ruby variable restructuring because I’m edgy and cool and on the bleeding edge (I am so not).
(Also, the .mt-2
is because I use Bootstrap and this matche the spacing of the React select to the standard Bootstrap form
input selects).