Replacing Select2 with Tom Select + Stimulus

We all used Select2. We all depended on it for a long time, for all our Select/Autocomplete needs. But it’s been showing signs of aging for quite a while, and it’s one of the last libraries that still keeps me tied to jQuery.

It was time to let go.

After quickly evaluating a few alternatives, I decided to take a closer look at Tom Select.

Tom Select was forked from selectize.js with the goal of modernizing the code base, decoupling from jQuery, and expanding functionality.

Well, that looks good to me.

And since it was renovating time, I also decided to consolidate all that JS used to make Select2 work with Ajax, filtering, etc. in a few Stimulus controllers.

All the examples shown here are coded with a Rails app in mind, but they can easily be adapted for any other stack.

The simple case

I wanted to make it as simple as possible to use the solution from the HTML code. The simplest solution would be just adding a data-controller to a select tag.

<%= f.select :city,
              City.to_select,
              { include_blank: true },
              data: {
                controller: 'ts--select'
              } %>

So, I installed tom-select and created a ts--select controller, using the new generator from stimulus-rails:

$ yarn add tom-select

$ rails g stimulus ts/select

I just imported TomSelect and created the instance on connecting

import { Controller } from "@hotwired/stimulus"
import TomSelect      from "tom-select"

// Connects to data-controller="ts--select"
export default class extends Controller {
  connect() {
    new TomSelect(this.element)
  }
}

It worked like a charm! For simple cases, when all you need is a select/options backed solution, that is all it takes.


Autocomplete with Ajax

Sometimes, when your list is too large, you may prefer a remote approach.

Keeping simplicity in mind, I wanted something like:

<%= f.select :search_city, [], {},
              placeholder: 'Type to search',
              data: {
                controller: 'ts--search',
                ts__search_url_value: autocomplete_cities_path
              } %>

The remote endpoint only needs to return a JSON Array with the matching results.

# app/controllers/cities_controller.rb

def autocomplete
  list = City.order(:name)
             .where("name ilike :q", q: "%#{params[:q]}%")

  render json: list.map do |u|
    {
      text: u.name,
      value: u.id
    }
  end

end

Thanks to Tom Select’s support for remote data and using the handy @rails/request.js, the actual implementation ended up quite straightforward.

// app/javascript/controllers/ts/search_controller.js

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

// Connects to data-controller="ts--search"
export default class extends Controller {
  static values = { url: String }

  connect() {

    var config = {
      plugins: ['clear_button'],
      valueField: 'value',
      load: (q, callback) => this.search(q, callback)
    }

    new TomSelect(this.element, config)
  }

  async search(q, callback) {
    const response = await get(this.urlValue, {
      query: { q: q },
      responseKind: 'json'
    })

    if (response.ok) {
      const list = await response.json
      callback(list)
    } else {
      console.log(response)
      callback()
    }
  }

}

Filtering other Select

Another very common use case is the need to filter the options of a select based on the selected value of another one. In this case, our controller has to include both selects. The markup I imagined for such a task would be something like:

<div data-controller="ts--filter"
     data-ts--filter-url-value="<%= filter_cities_path %>">

  <%= f.select :state,
                City.states_to_select,
                { include_blank: true },
                data: {
                  ts__filter_target: 'filter'
                } %>

  <%= f.select :filter_city, [], {},
                data: {
                  ts__filter_target: 'other'
                } %>

</div>

Again, the remote endpoint returns the filtered results

# app/controllers/cities_controller.rb

def filter
  list = City.order(:name)
             .where(state: params[:filter])

  render json: list.map { |u| { text: u.name, value: u.id } }
end

The stimulus controller is somewhat similar to the previous one, but with enough differences to require a new one

// app/javascript/controllers/ts/filter_controller.js

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

export default class extends Controller {
  static targets = [ "filter", "other" ]
  static values  = { url: String }

  connect() {

    this.filterTarget.addEventListener('change', ev => {
      if (this.selectedFilter)
        this.fetchItems()
      else
        this.clearItems()
    })

    if (this.selectedFilter) this.fetchItems()
  }

  async fetchItems() {
    const response = await get(this.urlValue, {
      query: { filter: this.selectedFilter },
      responseKind: 'json'
    })

    if (response.ok)
      this.setItems(await response.json)
    else
      console.log(response)
  }

  setItems(items) {
    this.clearItems()
    this.tomSelect.addOptions(items)
  }

  clearItems() {
    this.tomSelect.clear()
    this.tomSelect.clearOptions()
  }

  get selectedFilter() {
    return this.filterTarget.value
  }

  get tomSelect() {
    this._tomSelect ||= new TomSelect(this.otherTarget, {
      plugins: [ 'clear_button']
    })

    return this._tomSelect
  }

}

It checks for a selected value every time the State changes and on connecting. Then it fetches or clears the results accordingly.


Final Thoughts

Tom Select is a very useful library to implement advanced behaviour in select tags, and can replace select2 with advantages. We’ve seen examples of 3 different scenarios. Using Stimulus allows us to implement advanced funcionalities in the select, while keeping the html simple and enabling reuse of the code across different pages.

You can find a functional demo of this concept here
https://tom-select.herokuapp.com/

The source code can be found at
https://github.com/CoolRequest/tom-select-demo/

Comments

If you have any questions or feedback regarding this post, please leave your comment below. Keep in mind that comments are subject to moderation and will not be displayed immediately.

Mauricio Menegaz - 20/05/2022 10:17

Thank you for pointing that out. The demo app was built with ruby 3.0 and Rails 7. Different versions might require some small changes.

Peter - 15/05/2022 04:38

Very nice solution. Anyone able to get the CSS working for Tailwind CSS? Thanks.

Jerome - 20/12/2021 13:39

HTML over the wire has many merits. Before hotwire, the way to do autocomplete with filter was to have some intermediate step setting the filter. At this point Hotwire may actually be burdening the server pointlessly 3, 4, 5 times (as characters get added in the UI) per single request.

Assuming data that is static - say Nations > Regions > Municipalities - how could this approach connect to a microservice that could be dimensioned (and cache enabled!) for similar high volume queries, leaving the application to handling its native function?

Thomas Bindzus - 27/03/2022 07:55

I think it’s important to notice that there is a stylesheet, which should be included to make TomSelect work properly, this line in your @import “tom-select/dist/scss/tom-select.bootstrap5”; in your bootstrap.application.scss file.

Sandro Duarte - 31/01/2022 17:54

You can configure the loadThrottle, which is ‘The number of milliseconds to wait before requesting options from the server’. The default is 300ms. (https://tom-select.js.org/docs/)

SK - 11/05/2022 12:59

Great guide!

Note: The following does not work for all ruby versions:

render json: list.map do |u| { text: u.name, value: u.id } end

Instead, one should use (note the added parentheses):

render(json: list.map do |u| { text: u.name, value: u.id } end)