DEV Community

Cover image for Sort tables (almost) instantly with Ruby on Rails and StimulusReflex
David Colby
David Colby

Posted on • Originally published at colby.so

Sort tables (almost) instantly with Ruby on Rails and StimulusReflex

Today we’re going to use Ruby on Rails and StimulusReflex to build a table that sorts itself each time a user clicks on a header column.

Sorting will occur without a page turn, in less than 100ms, won't require any custom JavaScript, and we'll build the whole thing with regular old ERB templates and a little bit of Ruby.

The end result will be very fast, efficient, simple to reason about, and easy to extend as new functionality is required.

It'll be pretty fancy.

When we're finished, it will work like this:

A screen recording of a user clicking on column headers on a data table. With each click, the table sorts itself by that column and a triangle indicator appears next to the column used for sorting

You can view the finished product on Heroku, or find the full source on Github.

This article will be most useful to folks who are familiar with Ruby on Rails, you will not need any previous experience with Stimulus or StimulusReflex to follow along. If you’ve never worked with Rails before, some concepts here may be a little tough to follow.

Let’s get started!

Setup

As usual, we’ll start with a brand new Rails 6.1 application, with Tailwind and StimulusReflex installed. Tailwind is not a requirement, but it helps us make our table look a little nicer and the extra setup time is worth the cost.

If you’d like to skip the copy/pasting setup steps, you can clone down the example repo and skip ahead to the Building the Table section. The main branch of the example repo is pinned to the end of the setup process and ready for you to start writing code.

If you’re going to follow along with the setup, first, from your terminal:

rails new player_sorting -T
cd player_sorting
bundle add stimulus_reflex
bundle add faker
rake stimulus_reflex:install
rails g model Team name:string
rails g scaffold Player name:string team:references seasons:integer
rails db:migrate
Enter fullscreen mode Exit fullscreen mode

Assuming you’ve got Rails and Yarn installed, this will produce a brand new Rails 6.1 application (at the time of this writing), install StimulusReflex, and scaffold up the Team and Player models that we’ll use to build our sortable table.

If you don’t care to use Tailwind for this article, feel free to skip past this next section, Tailwind is a convenient way to make things look presentable, but if you just want to focus on sorting the table without any styling, Tailwind is not necessary!

If you want to install Tailwind, start in your terminal:

yarn add tailwindcss@latest postcss@latest autoprefixer@latest
npx tailwindcss init
mkdir app/javascript/stylesheets
touch app/javascript/stylesheets/application.scss

And then update tailwind.config.js:

module.exports = {
  purge: [
    './app/**/*/*.html.erb',
    './app/helpers/**/*/*.rb',
    './app/javascript/**/*/*.js',
    './app/javascript/**/*/*.vue',
    './app/javascript/**/*/*.react'
  ],
  darkMode: false,
  theme: {
    extend: {},
  },
  variants: {
    extend: {},
  },
  plugins: [],
}
Enter fullscreen mode Exit fullscreen mode

And postcss.config.js:

module.exports = {
  plugins: [
    require("tailwindcss")("./tailwind.config.js"),
    require("postcss-import"),
    require("postcss-flexbugs-fixes"),
    require("postcss-preset-env")({
      autoprefixer: {
        flexbox: "no-2009",
      },
      stage: 3,
    }),
  ],
}
Enter fullscreen mode Exit fullscreen mode

Next we’ll update app/javascripts/stylesheets/application.scss to import Tailwind:

@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
Enter fullscreen mode Exit fullscreen mode

And then include that stylesheet in app/javascripts/packs/application.js:

import "../stylesheets/application.scss"
Enter fullscreen mode Exit fullscreen mode

Finally, update the application layout to include the stylesheet generated by webpacker:

<%= stylesheet_pack_tag 'application', 'data-turbolinks-track': 'reload', media: 'all' %>
Enter fullscreen mode Exit fullscreen mode

Whew. We’re through the setup and ready to start building.

Building the table

First up, let’s get our bearings.

The core of our application is the Players resource. We are going to construct a table that displays all of the players in our database, with the players name, their team’s name, and their seasons played as columns.

We’ll only use the Team model created during setup in the context of Players, so we don’t need a controller or views for teams.

We’ll start by moving the table from the players index view to a partial. From your terminal:

touch app/views/players/_players.html.erb
Enter fullscreen mode Exit fullscreen mode

And fill that partial in with:

<div id="players" class="shadow overflow-hidden rounded border-b border-gray-200">
  <table class="min-w-full bg-white">
    <thead class="bg-gray-800 text-white">
      <tr>
        <th id="players-name" class="text-left py-3 px-6 uppercase font-semibold text-sm hover:cursor-pointer">
          <span>Name</span>
        </th>
        <th id="players-team" class="text-left py-3 px-6 uppercase font-semibold text-sm hover:cursor-pointer">
          <span>Team</span>
        </th>
        <th id="players-seasons" class="text-left py-3 px-6 uppercase font-semibold text-sm hover:cursor-pointer">
          <span>Seasons</span>
        </th>
      </tr>
    </thead>

    <tbody class="text-gray-700">
      <% players.each do |player| %>
        <tr>
          <td class="text-left py-3 px-6"><%= player.name %></td>
          <td class="text-left py-3 px-6"><%= player.team.name %></td>
          <td class="text-left py-3 px-6"><%= player.seasons %></td>
        </tr>
      <% end %>
    </tbody>
  </table>
</div>
Enter fullscreen mode Exit fullscreen mode

Most of the markup here is Tailwind classes for styling the table.

The functionally important pieces are the ids set on the wrapper div (#players) and the ids set on the table header cells. These ids will be used later to update the DOM when the user clicks to sort the table.

Next update the index view to use the new partial:

<div class="max-w-7xl mx-auto mt-12">
  <%= render "players", players: @players %>
</div>
Enter fullscreen mode Exit fullscreen mode

With these changes in place, we have a nice looking table ready to display the players in the database, but we don’t have any players. Since we’re going to be sorting, let’s make sure the database has plenty of data in it.

Copy this into db/seeds.rb:

['Dallas Mavericks', 'LA Clippers', 'LA Lakers', 'San Antonio Spurs', 'Boston Celtics', 'Miami Heat', 'New Orleans Pelicans'].each do |name|
  Team.create(name: name)
end

100.times do
  Player.create(name: Faker::Name.name, team: Team.find(Team.pluck(:id).sample), seasons: rand(25))
end
Enter fullscreen mode Exit fullscreen mode

And then, from your terminal:

rails db:seed
Enter fullscreen mode Exit fullscreen mode

Now start up your Rails server and head to localhost:3000/players.

If all has gone well, you should see a table populated with 100 randomly generated players.

A screenshot of a data table with columns for name, team, and seasons

Next up, we’ll build our sorting mechanism with StimulusReflex.

Sorting with StimulusReflex

To sort the table, we’re going to create a reflex class with a single action, sort.

When the user clicks on a table header, we’ll call the reflex action to sort the players and update the DOM.

We’ll start by generating a new Reflex. From your terminal:

rails g stimulus_reflex Table
Enter fullscreen mode Exit fullscreen mode

This generator creates both a reflex in app/reflexes and a related Stimulus controller in app/javascripts/controllers.

For this article, we won’t make any modifications to the Stimulus controller. Instead, we’ll focus on the reflex found at app/reflexes/table_reflex.rb

Fill that reflex in with:

class TableReflex < ApplicationReflex
  def sort
    players = Player.order("#{element.dataset.column} #{element.dataset.direction}")
    morph '#players', render(partial: 'players', locals: { players: players })
  end
end
Enter fullscreen mode Exit fullscreen mode

The first line of sort is a standard Rails ActiveRecord query. In it, we retrieve all of the players from the database, ordered by attributes sent from the DOM when the reflex action is triggered.

Reflex actions have access to a variety of properties. In our case, the property we’re interested in is element.

element is a representation of the DOM element that triggered the reflex and it includes all of the data attributes set on that element, accessible via element.dataset.

This means that in reflex actions, we can always access data attributes from the element that triggered the reflex as if we were working with that element in JavaScript. Handy.

For our purposes, we care about two data elements that don’t yet exist in the DOM — column and direction. The ActiveRecord query to retrieve and order players uses those values to know which column to order the results by, and in which direction (ascending or descending) the results should be ordered.

After we’ve retrieved the ordered list of players from the database, we use a selector morph to update the DOM, replacing the content of the players partial we created earlier with the updated list of players.

Our reflex is built, but there’s no way for a user to trigger the reflex. Let’s add that next.

In the players partial, update the header row like this:

<tr>
  <th 
    id="players-name" 
    class="text-left py-3 px-6 uppercase font-semibold text-sm hover:cursor-pointer"
    data-reflex="click->Table#sort"
    data-column="name"
    data-direction="asc"
  >
    <span>Name</span>
  </th>
  <th 
    id="players-team" 
    class="text-left py-3 px-6 uppercase font-semibold text-sm hover:cursor-pointer"
    data-reflex="click->Table#sort"
    data-column="teams.name"
    data-direction="asc"
  >
    <span>Team</span>
  </th>
  <th 
    id="players-seasons" 
    class="text-left py-3 px-6 uppercase font-semibold text-sm hover:cursor-pointer"
    data-reflex="click->Table#sort"
    data-column="seasons"
    data-direction="asc"
  >
    <span>Seasons</span>
  </th>
</tr>
Enter fullscreen mode Exit fullscreen mode

Here we’ve updated each header cell with the three data attributes we need for the reflex to be triggered and to run successfully.

First, data-reflex is used to tell StimulusReflex that this element should trigger a reflex when some action occurs. In our case, it will be called on click.

Each cell also gets a unique data-column value, which we use to sort the players result by the matching database column. Finally, each header cell also starts with a direction of asc which is used to set the direction of the order query.

Let’s look at the Reflex code again and review what’s happening now that we’ve updated the DOM to call this reflex.

def sort
  players = Player.order("#{element.dataset.column} #{element.dataset.direction}")
  morph '#players', render(partial: 'players', locals: { players: players })
end
Enter fullscreen mode Exit fullscreen mode

The sort method we’ve defined matches the name of the data-reflex on each header cell. When the header cell is clicked, this reflex will run.

The header cell that that the user clicks will be passed to the reflex as element, giving us access to the element’s data attributes, which we access through element.dataset.

Once the value of players is set by the database query, we use a selector morph to tell the browser to update the element with the id of players with the content of the players partial, using the updated, reordered list of players.

This is the magic of StimulusReflex in action. With just a couple of data attributes and a few lines of simple Ruby code, our users can now click on a table header and, in < 100ms, they’ll see a table sorted to match their request.

Refresh the page and try it out for yourself. If all has gone well, clicking on a header cell should sort the table by that column in ascending order. While this is nice, we have a few more items to address before our work is complete.

Next up, we’ll address an error with the order query, and then finish this article by modifying the sort reflex to allow users to sort in both ascending and descending order and display visual feedback to indicate what column is being sorted.

Fixing an ordering error

First, sharp eyed readers might have noticed that sorting by the team name column doesn’t work yet.

Each header cell’s column data attribute matches a column in the database, so we can generate the order query dynamically. This works fine for the name and season because those columns live on the Players table. ActiveRecord knows how to order by name and seasons without any extra effort.

For the team name column, we’re passing teams.name to the order call in our query, which ActiveRecord trips over with an error like this:

# The text will vary depending on the database adapter you're using!
Reflex Table#sort failed: SQLite3::SQLException: no such column: teams.name
Enter fullscreen mode Exit fullscreen mode

We can fix this by updating the query slightly:

players = Player.includes(:team).order("#{element.dataset.column} #{element.dataset.direction}")
Enter fullscreen mode Exit fullscreen mode

Here we added includes(:team) to the existing order query, making the Teams table accessible in the order clause and fixing the “no such column” error that was thrown when attempting to sort by team name.

Note that joins instead of includes would also fix the error, but since we need to use team name when we render the players partial (to display each player’s team), includes is the better choice.

Before moving on, you’ll notice that we are using user-accessible values to generate a SQL query — anyone can modify data-attributes in their browser’s dev tools.

Prior to Rails 6, this could have opened up our application to SQL injection; however, since Rails 6, Rails will raise an error automatically if anything but a table/column name + a sort direction are passed in to order.

Adding descending ordering

With the work we've done so far, sorting the table works great as long as the user only wants to sort in ascending (A - Z) order. Since the sort direction is read from a data attribute that is always “asc”, there is no way to sort in descending (Z - A) order. Let's add that functionality next.

Before jumping in to the code, let’s outline the desired user experience.

When a user clicks on a column header, the table should be sorted by that column, in ascending order. When the user clicks on the same column header again, the table should be sorted by that column in descending order. And then we alternate, forever, between ascending and descending on subsequent clicks.

Sorting by a different column should always sort in ascending order on the first click.

Here’s a gif of what we’re aiming for:

A screen recording of a user clicking on column headers to sort a data table in ascending and descending order

To achieve the desired user experience, we need to track the next sort direction for each header cell, so that when the sort reflex is called, the right direction can be sent to the order query.

To do this, we’re going to take advantage of CableReady’s tight integration with StimulusReflex. We’ll update the sort reflex to include a cable_ready operation that changes the direction data attribute of the element that triggered the reflex.

To do this, update TableReflex like this:

def sort
  # snip
  set_sort_direction
end

private

def next_direction(direction)
  direction == 'asc' ? 'desc' : 'asc'
end

def set_sort_direction
  cable_ready
    .set_dataset_property(
      selector: "##{element.id}",
      name: 'direction',
      value: next_direction(element.dataset.direction)
    )
end
Enter fullscreen mode Exit fullscreen mode

Here we’ve added two private methods to the TableReflex class. next_direction is a simple helper method that takes the current value of direction and returns the next value.

set_sort_direction is more interesting. In it, we use CableReady’s set_dataset_property operation to set the value of the element’s direction data attribute to the value of next_direction .

Finally, we call set_sort_direction in the sort reflex, which adds the set_dataset_property operation to the queue each time the sort reflex runs.

With this in place, refresh and click on the same column multiple times to see that each click reorders the table, toggling between ascending and descending order.

Order of operations: Not just for math class

Before moving on, it is important to pause and think about how this code works. When a reflex includes CableReady operations, a specific order of operations is always followed.

First, broadcasted CableReady operations execute. Next, StimulusReflex morphs execute. Finally, CableReady operations that are not broadcasted execute (that’s our set_dataset_property operation).

Because the StimulusReflex morph runs before the CableReady operation, each table header cell has its direction data attribute reset to asc when sort is triggered. This behavior lets us “reset” sort directions when moving between columns without having to add logic in the partial.

Immediately after the StimulusReflex morph, set_dataset_property runs and updates the value of direction on the currently active sort column.

If we appended .broadcast to the set_dataset_property operation, the direction property would be updated before the StimulusReflex morph, causing the CableReady update to be overwritten by the morph, breaking the ability to sort in descending order.

This order of operations is important to understand, and helps unlock a new level of functionality within reflexes.

Before moving on, now that we understand the order of operations in reflex actions, we can use that knowledge to make a small optimization to the sort reflex.

We know that every time the reflex runs, each header cell will have its data-direction value set to asc before the active sort column is updated by the CableReady set_dataset_property operation.

Since the value of data-direction is already asc, if the next sort direction is asc, set_dataset_property won’t do anything useful.

Let’s update sort to skip the CableReady operation in that case:

def sort
# snip
  set_sort_direction if next_direction(element.dataset.direction) == 'desc'
end
Enter fullscreen mode Exit fullscreen mode

Now set_sort_direction will only be run when necessary, simplifying our DOM updates at the cost of slightly more complexity in our ruby code.

Let’s finish up our sortable table implementation by adding a visual indicator to the table when sorting is active.

Sort direction visuals

To indicate which column is being sorted, and in which direction, we’ll draw a triangle with CSS, with an upward pointing triangle indicating ascending order, and a downward triangle indicating descending order.

Only the column that is being used for sorting will display the icon.

When we’re finished, the indicator will look like this:

A screenshot of a data table with a triangle pointing upward positioned to the left of a column header labeled Team, indicating the table is sorted in ascending order by team name

Let’s start with the CSS.

We’ll insert the CSS directly into our main application.scss file to keep things simple:

.sort {
  position: absolute;
  top: 5px;
  left: -1rem;
  width: 0;
  height: 0;
  border-left: 6px solid transparent;
  border-right: 6px solid transparent;
}

.sort-desc {
  border-top: 8px solid #fff;
}

.sort-asc {
  border-bottom: 8px solid #fff;
}
Enter fullscreen mode Exit fullscreen mode

We’ll use the base .sort class along with a dynamic .sort-asc or .sort-desc to display the sort indicator. If you’re interested in how this CSS works, this is a nice introduction to drawing shapes with CSS.

With the CSS ready, next we’ll create a partial to render the indicator, from your terminal:

touch app/views/players/_sort_indicator.html.erb
Enter fullscreen mode Exit fullscreen mode

And fill that in with:

<div class="relative">
  <span class="sort sort-<%= direction %>"></span>
</div>
Enter fullscreen mode Exit fullscreen mode

Nothing fancy here, just appending the local direction variable to the class name to ensure our triangle points in the right direction.

We’ll finish up by updating the sort reflex to insert the sort indicator into the DOM, again relying on a CableReady operation that runs immediately after the StimulusReflex morph.

class TableReflex < ApplicationReflex
  def sort
    # snip
    insert_indicator
  end

  private

  # snip
  def insert_indicator
    cable_ready
      .prepend(
        selector: "##{element.id}",
        html: render(
          partial: 'players/sort_indicator',
          locals: { direction: element.dataset.direction }
        )
      )
  end
end
Enter fullscreen mode Exit fullscreen mode

Here we added another private method to the reflex to handle inserting the sort indicator when the sort reflex is called.

insert_indicator uses CableReady’s prepend operation to insert the content of the sort_indicator partial into the DOM, before the target element’s first child.

With this in place, we can refresh the page and see the sort indicator added each time the sort reflex runs, pointing up for ascending sorts and down for descending sorts:

A screen recording of a user clicking on column headers on a data table. With each click, the table sorts itself by that column and a triangle indicator appears next to the column used for sorting

An implementation note

During this article I made a choice to use cable_ready operations to add the sort indicator and update the data attribute.

Instead of cable_ready operations, another approach would be to assign instance or local variables for things like "active column" and "next direction" during the sort reflex. We could then read those variables when rendering the players partial, using them to render the sort indicator and set the next sort direction.

This approach would allow us to eliminate the cable_ready operations; however, doing so would complicate the view. Either approach is fine, my personal preference is to rely on the very fast cable_ready operations to simplify the view.

Using cable_ready also has the added benefit of letting us talk more about how StimulusReflex works, which is a bonus in a tutorial article like this one. As you spend more time with StimulusReflex, experiment with different approaches and find what works best for you.

Wrapping up

Today we built a sortable table interface with Ruby on Rails, StimulusReflex, and CableReady. Our table is fast, updates efficiently, is easy to extend, and is no where near production ready yet. What we built today was part one of a two part series. Next up, we’ll extend the sortable table by adding filtering and pagination, getting closer to the full-featured implementation seen in Beast Mode.

While there are numerous ways to implement a sortable table interface, for Rails developers, StimulusReflex is worthy of strong consideration. SR’s fast, efficient mechanisms for updating and re-rendering the DOM, including bypassing ActionDispatch’s overhead with selector morphs, allow us to sort and render the updated table extremely quickly, with minimal code complexity or additional mental overhead. Its tight integrations with CableReady and Stimulus combine into an extremely powerful tool in any Rails developers kit.

To go further into StimulusReflex and CableReady:

  • Review StimulusReflex patterns for thoughtfully designed solutions in StimulusReflex, including filterable for working with complex sorting and filtering requirements
  • Join the StimulusReflex discord and connect with other folks building cool stuff with StimulusReflex, CableReady, and Rails

That’s all for today, as always, thanks for reading!

Top comments (1)

Collapse
 
davidcolbyatx profile image
David Colby

Hey Leonid, I published an article this morning implementing the same functionality with turbo frames that might be helpful for you to see how the same thing can be done with frames: dev.to/davidcolbyatx/sort-tables-a...

For me personally, I find myself using SR, CR, and Frames inside the same application, Frames do really simple things (like inline editing a record in a table) very well.

StimulusReflex is what I reach for on more complex front end interactions when I know I'm going to need before/after callbacks, and when I don't want to pollute my controller with non-RESTful routes or conditionals to try to render a partial sometimes and full-page turn other times. Both of those issues come up frequently when relying entirely on Frames and Streams.

CableReady tends to be a direct, complete replacement for Streams for me in most cases. Streams are great, and work well, but I've just found myself enjoying building with CableReady more than with Streams after using both extensively. I suspect some teams will stick with Streams and be very successful with them, especially teams that got used to using js.erb templates since Streams end up looking and feeling very similar to that approach.

A helpful point of comparison for Streams vs. CR might be the articles I've written on using each option to submit a modal form:

With Frames and Streams: dev.to/davidcolbyatx/handling-moda... (this article could use some updates, but the general approach still stands!)

With CableReady and mrujs: dev.to/davidcolbyatx/server-render...