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.

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?