Boost Your Rails Forms: Creating Options On-the-Fly with Tom-Select, Stimulus, and Hotwire

When we have an object with a belongs_to relationship, we often use a select tag to list the data of the related entity. For example, we might have a select with all the available categories for a product at the moment of its creation. This way, we select the category, and when we save the product, the association is saved with the product.

A common problem that arises is that if the category of the product being created doesn’t yet exist, there’s no way to create it on-the-fly. That is, if I am creating the product “Shirt” and the category “Clothing” doesn’t exist yet, I have no way to create it without leaving the form.

In this article, we will see how to do it using tom-selectStimulus, and Hotwire. The code shown in this article is available in the following GitHub repository.

Disclaimer: To understand the content of this article, you will need at least basic knowledge of Hotwire and Stimulus.

Creating Movies

The domain we will use as an example is the world of movies. What we are going to build looks something like this:

Creating the Form

The movie form is very basic. Each movie has a title and a genre:

<%= form_with(model: movie) do |form| %>
<%= form.label :title %>
<%= form.text_field :title, placeholder: 'Ex: Forrest Gump' %>

<%= form.label :genre %>
<%= form.collection_select(
:genre_id,
Genre.order(:name), :id, :name,
{ prompt: 'Select Genre' },
id: 'genres-select',
) %>

<%= form.submit 'Create Movie' %>
<% end %>

With this, we would have something like this:

Tom Select

The first thing we are going to do is add tom-select, which will allow us to have a much more powerful dropdown than the one that comes with HTML. To do this, we run the following command:

bin/importmap pin tom-select

Next, we will create a Stimulus controller, which we will associate with the select tag, so that it uses tom-select instead of the basic HTML select tag:

// app/javascript/controllers/select_controller.js
import { Controller } from "@hotwired/stimulus"
import TomSelect from "tom-select"

export default class extends Controller {
// We bind the select to tom-select on connect
connect() {
new TomSelect(this.element)
}
}
<!-- app/views/movies/_form.html.erb -->

<%= form.collection_select(
:genre_id,
Genre.order(:name), :id, :name,
{ prompt: 'Select Genre' },
id: 'genres-select',
data: { controller: 'select' }
) %>

That’s it! With this, we now have tom-select integrated into our genre dropdown. In other words, tom-select will hide our HTML select and create a new component on top of it that will allow us to list and search genres.

Creating a Genre

To create a genre, we need to detect when the user searches for a non-existent option, persist it in the database, and then add it as an <option> to the select. If we look at the tom-select documentation, we will see that it offers a configuration option, in the form of a function, that will be executed when the searched option does not exist. This means we can parameterize a function in the instantiation of TomSelect that will be invoked when the option the user is looking for is not found.

The option I am referring to is create. Let’s modify the tom-select configuration as follows:

connect() {
new TomSelect(
this.element, {
create: (input, callback) => {
// "input" is the entered value
alert(`trying to create ${input}`)
},
}
)

The result is as follows:

In this function, we could make a POST request to /genres to create the genre and then, using Hotwire, add the new option to the select.

Let’s modify the Stimulus controller as follows:

connect() {
new TomSelect(
this.element, {
create: (input, callback) => {
if(prompt('New genre', input)) {
post(`/genres?name=${input}`, { responseKind: 'turbo-stream' })
} else {
callback(false)
}
},
}
)
}

I added a prompt in case the user wants to modify the entered option. This could be useful for adjusting the text entered.

To perform the POST, I installed request.js and added the corresponding import to the Stimulus controller.

bin/importmap pin @rails/request.js

The controller now looks like this:

import { Controller } from "@hotwired/stimulus"
import TomSelect from "tom-select"
import { post } from '@rails/request.js'

export default class extends Controller {
connect() {
new TomSelect(
this.element, {
create: (input, callback) => {
if(prompt('New genre', input)) {
post(`/genres?name=${input}`, { responseKind: 'turbo-stream' })
} else {
callback(false)
}
},
}
)
}
}

Finally, what remains is to create the action to persist the genre in genres_controller and add the option to the select:

# app/controllers/genres_controller.rb
def create
@genre = Genre.create(name: params[:name])
end
<!-- app/views/genres/create.turbo_stream.erb -->
<%= turbo_stream.append 'genres-select' do %>
<option value="<%= @genre.id %>"> <%= @genre.name %> </option>
<% end %>

In this way, we are adding a new option to our HTML select. However, remember that tom-select hides our select and adds a new component on top of it. The synchronization between these two components does not happen automatically.

To achieve this, we can add an event in our Stimulus controller to detect when an option is added and, once this happens, synchronize the corresponding tom-select component.

We can achieve this by adding the <option> as a target in our Stimulus controller and using the targetConnected event:

<!-- app/views/genres/create.turbo_stream.erb -->
<%= turbo_stream.append 'genres-select' do %>
<option value="<%= @genre.id %>" data-select-target="item"> <%= @genre.name %> </option>
<% end %>

We add the target item and create the itemTargetConnected event to synchronize tom-select.

export default class extends Controller {
static targets = ['item']

connect() {
...
}

itemTargetConnected(element) {
this.element.tomselect.sync()
this.element.tomselect.setValue(element.value)
}
}

That’s it! With this, we can now create options on-the-fly using tom-select and Hotwire.