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:
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
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: [],
}
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,
}),
],
}
Next we’ll update app/javascripts/stylesheets/application.scss
to import Tailwind:
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
And then include that stylesheet in app/javascripts/packs/application.js
:
import "../stylesheets/application.scss"
Finally, update the application layout to include the stylesheet generated by webpacker:
<%= stylesheet_pack_tag 'application', 'data-turbolinks-track': 'reload', media: 'all' %>
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
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>
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>
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
And then, from your terminal:
rails db:seed
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.
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
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
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>
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
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
We can fix this by updating the query slightly:
players = Player.includes(:team).order("#{element.dataset.column} #{element.dataset.direction}")
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:
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
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
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:
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;
}
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
And fill that in with:
<div class="relative">
<span class="sort sort-<%= direction %>"></span>
</div>
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
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:
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)
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...