Usable multi-selects in Rails forms

Look. 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 the name attribute Rails would have given this input if we used f.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 a has_and_belongs_to_many association). Easiest way to figure out what to put here is to actually render out the f.select :attr_name and inspect the name 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 the value fields in your collection prop. For example, the currently present IDs on my_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).