How to build a reusable sortable table in Ruby on Rails with Stimulus

This article is all about writing a sortable table with reusable software modules in Stimulus and the Ruby on Rails backend too.

The result and its limitations

  • We will pass a certain sort key via a query parameter to the backend.

  • A query parameter for the sorting direction will be provided as a query parameter too.

  • This won't allow you to sort according to multiple sorting criteria.

  • You won't be able to have multiple sortable tables on one page with this approach. This would require further tweaking.

Building the module

The module is divided into the frontend Sorting Class, Stimulus Controller and the backend SortBy functionality.

Sorting Class in the frontend

To make the sorting reusable for other tables and other use cases we will encapsulate the following sorting behavior into its own class named Sorting.

  • applySort(sortKey: string) as part of the public API will take care of changing the URL parameter sort_key and toggling the query parameter sort_order depending on its current value.

  • we will provide a factory method fromWindowUrl() to make a Sorting Object from the current window.location.href string.

  • sort_order can be either ASC or DESC.

The implementation will look like the following:

class Sorting {
    sortKey;
    sortOrder;

    constructor(sortKey, sortOrder) {
        this.sortKey = sortKey;
        this.sortOrder = sortOrder;
    }

    changeDirection() {
        this.sortOrder = this.sortOrder === 'DESC' ? 'ASC': 'DESC';
    }

    applySort(key) {
        const wantsToChangeDirection = this.sortKey?.toLowerCase() === key.toLowerCase();

        this.sortKey = key;
        if (wantsToChangeDirection) {
            this.changeDirection();
        } else {
            this.sortOrder = 'DESC';
        }

        this.redirectToSortURL();
    }

    redirectToSortURL() {
        const url = new URL(document.URL);
        const searchParams = new URLSearchParams(url.search);

        // redirect to URL
        searchParams.set("sort_key", this.sortKey);
        searchParams.set("sort_order", this.sortOrder)
        url.search = decodeURIComponent(searchParams.toString());
        window.location.href = url.toString();
    }

    static fromWindowUrl() {
        const url = new URL(document.URL);
        const searchParams = new URLSearchParams(url.search);

        const sortKey = searchParams.get("sort_key");
        const sortOrder = searchParams.get("sort_order")

        return new Sorting(sortKey, sortOrder);
    }
}

Abstracting this behavior into its class will make this snippet easier to understand, less dependent and ultimately easier to test.

Integrating the Sorting Class in a Stimulus Controller

In the connect Stimulus hook we will call the previously created Sorting#fromWindowUrl() factory and saving it as an instance variable of the controller.

// sort_controller.js
import {Controller} from '@hotwired/stimulus';

export default class extends Controller {

    static targets = [...super.targets ];

    connect() {
        this.sorting = Sorting.fromWindowUrl();
    }

    sort(event) {
        const sortingKey = event.target.innerHTML;

        this.sorting.applySort(sortingKey)
    }
}

You can see the Stimulus Controller is incredibly small and easy to read.

Building the SortBy class in Ruby

The SortBy class exists to neatly encapsulate the sorting behavior. Theoretically one could just put those three lines into the controller but this will make it harder to understand and clutters the codebase if used on multiple pages.

# sort_by.rb
class SortBy

  def initialize(params)
    @sort_key = params[:sort_key] || 'sequential_id'
    @sort_order = params[:sort_order] || 'DESC'
  end

  def sort(model)
    model.order("#{@sort_key} #{@sort_order}")
  end
end

This is just the minimalistic excerpt of the actual implementation that I have ended up using.

It lacks restriction of invalid sort_keys and sort_orders.

Bringing it all together

HTML Template

Keep an eye on the Stimulus directives.

<table data-controller="sort">
  <thead>
    <tr>
        <% %i[sequential_id amount name size].each do |col| %>
          <th
             data-action="click->sort#sort"
             data-sorting-value="<%= col %>"
          >
            <%= col %>
          </th>
        <% end %>
    </tr>
  </thead>
...
  <tbody>
    <%= items.each do |item| %>
      <tr>
        <td><%= item.sequential_id></td>
        <%# all other cols... #>
      </tr>
    <% end %>
  </tbody>
</table>

Telling the index action to sort

Last but not least you will need to tell the index action to handle the query parameters.

Do this by instantiating a new SortBy object with the parameters of the request and calling the sort(filtered_items) method.

class UserController

    def index
        @items = User.all
        @items = SortBy.new(params).sort(@items)
    end

end

This might look like an error will be raised if you don't check that the params hash contains the sort key and sort direction, at first. But SortBy will take care of providing default values.

Please also note that this is not the most generic approach, it is a great middle way for implementing the sort fast and extensible.

The original use case is even more specific. That's why there is the sequential_id default value in SortBy.

Recap

If you need to implement a basic table header sorting fast with Ruby on Rails and StimulusJs, I hope I could inspire you with my approach.

This thing comes without any external dependencies, besides Active Record and took me less than 30 minutes to implement.

Thank you for reading this far. I would be very thrilled if you were to share this with a developer friend of yours.