Building a sortable table with Turbo 8's page refreshes

Scroll-restoring, morphing page refreshes are one of the headline features of Turbo 8. Page refreshes make it easier than ever to build modern, SPA-like experiences with Turbo and Ruby on Rails.

One of the most immediate wins for page refreshes is making it simpler to build search and filter UIs, so today we are going to get practical with Turbo 8 by building a sortable table view with page refreshes. I wrote about building sortable tables with Turbo several years ago; back when that article was written we needed to lean heavily on Turbo Frames. With the new tools in Turbo 8 at our disposal, we can simplify our code to the point that we will be writing code that will look familiar to anyone that’s worked in Rails before. Turbo’s page refreshes blend into the background and let us write simple, efficient controllers and views without sacrificing the smooth feel of a single page application.

The application will allow users to sort a list of Players by their attributes. The players will be displayed in a table, and users will click on the table headers to apply their desired sort order. When we are finished, our project will work like this:

To follow along with this article, you should have be comfortable with Ruby on Rails and have Ruby 3 and Rails 7 ready to run locally. You do not need any experience with Turbo, I will fill you in as we work.

Let’s dive in.

Setup

If you prefer to skip past the setup, you can clone this Github repo and skip ahead to the next section.

To follow along locally, create a new Rails application with Tailwind added from your terminal:

rails new turbo_8_refresh_sorting --css=tailwind
cd turbo_8_refresh_sorting
bin/setup

The core of our application is a list of Players that users can sort by name, team, and the number of seasons they played. Staying in your terminal, generate the models and scaffold up a basic Player resource to build on. Staying in your terminal:

rails g model Team name:string
rails g scaffold Player name:string team:references seasons:integer
rails db:migrate

Next use faker to generate test data so we can see our sort mechanism working. From your terminal:

bundle add faker

And then hop into the rails console with bin/rails c and run this code to generate test data in your local database:

['Dallas Mavericks', 'Denver Nuggets', 'Phoenix Suns', '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

Finish setup by creating a basic table structure on the players index page at app/views/players/index.html.erb:

<div class="max-w-7xl mx-auto mt-12">
  <div 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>
</div>

Here we are rendering an HTML table with columns for player name, team name, and number of seasons played, and then rendering each player in the list of @players set by the PlayersController#index action. At this point you can boot up the Rails app with bin/dev and head to http://localhost:3000/players. If all has gone well you should a static list of the Players that we generated with faker.

A screen shot of a website table layout, displaying a list of players with columns for player name, team, and number of seasons played.

A static table is not very exciting. Next let’s make it possible to sort the table when a user clicks on the column headers.

Sorting the table

In previous versions of Turbo, the best method for building sorting or filtering features was Turbo Frames. Without too much code or complexity you could get a SPA-like experience that maintained the user’s scroll position and updated the user’s results with data from the server very quickly. Turbo Frames still work fine in Turbo 8 (you do not need to go rip them all out before upgrading), but we can simplify our code with the addition of morphing, scroll preserving page refreshes. With a Turbo-powered refresh, we can write standard Rails code that provides exactly the same experience without any extra views, routes, or controller code.

First, make the table headers clickable links. When a user clicks on any of the table headers, we will sort the table by that column in ascending order.

Head to app/views/players/index.html.erb and update it:

<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">
      <%= sort_link(column: "name", label: "Name") %>
    </th>
    <th id="players-team" class="text-left py-3 px-6 uppercase font-semibold text-sm hover:cursor-pointer">
      <%= sort_link(column: "teams.name", label: "Team") %>
    </th>
    <th id="players-seasons" class="text-left py-3 px-6 uppercase font-semibold text-sm hover:cursor-pointer">
      <%= sort_link(column: "seasons", label: "Seasons") %>
    </th>
  </tr>
</thead>

Here we replaced the text of the <th> elements in the table with calls to a sort_link method that takes the column to sort by and label text to display in the UI.

sort_link has not been defined yet, do that next in app/helpers/players_helper.rb:

module PlayersHelper
  def sort_link(column:, label:)
    link_to(label, players_path(column: column))
  end
end

Each call to sort_link returns a link_to pointing to Players#index with column in the URL parameters so that we can use the column from the link to apply the user’s desired sort value to the list of players.

Head to the PlayersController at app/controllers/players_controller.rb and update the index action to use the column param to order the list of players:

def index
  if params[:column].present?
    @players = Player.includes(:team).order("#{params[:column]}")
  else
    @players = Player.includes(:team).all
  end
end

At this point you can refresh the players index page and see that clicking the header links sorts the table by the column you clicked. But you will notice that each click performs a full page turn, replacing the entire body of the page and resetting the user’s scroll position back to the top of the page. That’s not the experience we are looking for!

Let’s use a Turbo page refresh, with morphing and scroll preservation to make the sort feel like a seamless, on-page update.

We need to add two meta tags to the <head> of the players index page to enable Turbo refreshes. These meta tags tell Turbo to morph page refreshes with idiomorph and to preserve the user’s scroll position during the refresh.

The meta tags we need are:

<meta name="turbo-refresh-method" content="morph">
<meta name="turbo-refresh-scroll" content="preserve">

turbo-rails provides a handy view helper we can use to generate both meta tags at once. Head to app/views/players/index.html.erb and add the helper:

<%= turbo_refreshes_with method: :morph, scroll: :preserve %>

And then update app/views/layouts/application.html.erb to yield the head contentprovided by turbo_refreshes_with:

<% if content_for? :head %>
  <%= yield :head %>
<% end %>

You can add turbo_refreshes_with directly into the <head> in app/views/layouts/application.html.erb if you prefer:

<head>
  <%= turbo_refreshes_with method: :morph, scroll: :preserve  %>
  <%= yield :head %>
</head>

Adding these meta tags only on pages we need them (by calling turbo_refreshes_with in the players index view) is the safest approach, especially if you are migrating an existing application to Turbo 8 and want to avoid any surprising application behavior with refreshes and scroll preservation in your existing code.

You can also add the meta tags individually with the dedicated helpers for each tag:

<%= turbo_refresh_method_tag :morph %>
<%= turbo_refresh_scroll_tag :preserve %>

Whichever route you choose, once you have the required meta tags in the <head> of the players index page, head to app/helpers/players_helper.rb to update the sort_link helper:

module PlayersHelper
  def sort_link(column:, label:)
    link_to(label, players_path(column: column), data: { turbo_action: 'replace' })
  end
end

Here we added data-turbo-action to the link_to. The addition of this data attribute tells Turbo to clicks on the link as a replace visit instead of an advance visit. There are some subtleties with the implementation of Turbo refreshes when you want a refresh to be triggered by a direct visit to a URL instead of by a redirect initiated by a form submission. It is worth a brief break from coding to talk through these subtleties.

Turbo only triggers a refresh when a visit is a replace visit to the same pathname as the previous page; however, the default visit type for a visit in Turbo is advance. In our case, we are rendering links back to the same URL with different URL parameters to change the sort options. Turbo will treat each of those clicks as an advance visit and will not morph the page. Instead an advance visit treat will be treated as a normal navigation with the full body replaced and scroll position set to the top of the page.

That is not the behavior we are looking for, so we override the turbo-action to specify a replace visit. Doing this will cause Turbo to morph in the new content and maintain scroll position.

This can be a bit of a tricky concept. You do not always need to specify data-turbo-action to get refresh behavior. Redirecting after a POST is automatically treated by Turbo as a replace visit, for example. The intricacies of this can be traced through the code and are best captured in a currently (as of March 2024) open PR to the Turbo documentation. It is worth reading through the discussion on this PR to understand the current behavior in more depth.

It is also important to understand that using a replace visit means that the browser’s back button will not navigate through each change to the sort order. Because a replace visit replaces the last entry in the history stack each time, if you apply a few different sort directions and then click the back button in your browser, you will be taken back to the last page you were on before your initial visit to /players. This is a tradeoff of using page refreshes in this manner — if maintaining a complete navigation history as sorting options change is important for your use case, you can use Turbo Frames to implement sorting rather than using morphing refreshes. There will be more code to write, but you will get complete browser history as compensation for the extra work.

Once we have this change in place, we have written all the code we need to implement Turbo refreshing with page morphing and scroll preservation. Reload http://localhost:3000/players, scroll down the page a bit, and then click on the page header and see that the table updates while scroll position is maintained.

Magical.

Sorting only in one direction is not very useful. Let’s finish up the sortable table implementation by swapping between ascending and descending sort order. No extra Turbo required.

Sort both directions

First, we will update the sort_link helper to include a direction parameter that toggles between ascending and descending order. Head to app/helpers/players_helper.rb and update it:

module PlayersHelper  
  def sort_link(column:, label:)
    direction = column == params[:column] ? next_direction : 'asc'
    link_to(label, players_path(column: column, direction: direction), data: { turbo_action: 'replace' })
  end

  def next_direction
    params[:direction] == 'asc' ? 'desc' : 'asc'
  end
end

Here we add a direction parameter to the link_to. If the column is currently sorted in ascending order, the next click on that column will sort in descending order, otherwise the next click on the column will sort in ascending order.

Now head to app/controllers/players_controller.rb and update it to apply the new direction parameter to the order query:

@players = Player.includes(:team).order("#{params[:column]} #{params[:direction]}")

Note that we are using user supplied parameters directly in an ActiveRecord query without sanitizing them. There is nothing stopping a malicious user from attempting an attack by passing in dangerous values to the column and direction params. Fortunately, Rails automatically sanitizes parameters passed to order to keep us safe.

Since users can sort by any column and in ascending or descending order, we should tell them which column they are currently sorting by, and in which direction. Let’s finish up this tutorial by adding a sort indicator to the table header. Head back to app/views/players/index.html.erb and update the <thead> element:

<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 relative">
      <%= show_sort_indicator_for("name") %>
      <%= sort_link(column: "name", label: "Name") %>
    </th>
    <th id="players-team" class="text-left py-3 px-6 uppercase font-semibold text-sm hover:cursor-pointer relative">
      <%= show_sort_indicator_for("teams.name") %>
      <%= sort_link(column: "teams.name", label: "Team") %>
    </th>
    <th id="players-seasons" class="text-left py-3 px-6 uppercase font-semibold text-sm hover:cursor-pointer relative">
      <%= show_sort_indicator_for("seasons") %>
      <%= sort_link(column: "seasons", label: "Seasons") %>
    </th>
  </tr>
</thead>

Here we added calls to show_sort_indicator_for to each column header. This helper method will render a sort indicator next to the header text if the table is currently sorted by that column. That helper method does not exist yet, so head to app/helpers/players_helper.rb to define it:

def sort_indicator
  tag.span(class: "sort sort-#{params[:direction]}")
end

def show_sort_indicator_for(column)
  sort_indicator if params[:column] == column
end

show_sort_indicator_for renders the sort_indicator helper when appropriate, otherwise it renders nothing.

Finally, we need a little CSS for the sort indicator tag. Add the CSS in app/assets/stylesheets/application.tailwind.css:

.sort {
  position: absolute;
  top: 1rem;
  left: 0.5rem;
  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;
}

With this last change in place, refresh the players index page and see that you can sort columns in both directions. As you apply sorting, you will see that your scroll position is preserved and a nice little arrow appears to show the user which column they are sorting by, and in which direction.

That is all for this tutorial, thank you for following along! As always, you can find the complete code for this tutorial on Github. Great work!


Wrap up

Today we learned how we can use page morphing and scroll preservation in Turbo 8 to build a simple table filtering mechanism in Ruby on Rails. This implementation can replace the more complex versions of this experience that could be built in previous versions of Turbo. Implementations without page refreshing typically relied on Turbo Frames or Turbo Streams to maintain scroll position and provide the snappy-feel that we can now get out of the box with Turbo 8 and plain old Rails code.

If you want to learn more about Turbo 8 and page refreshing, you may find these resources useful:

  • I wrote this piece to dive into more detail about morphing page refreshes on Rails
  • The announcement post from Jorge Manrubia provides more details about the motivations and real world use cases for refreshing
  • Also linked above, this PR on the Turbo docs is useful for tracing through some of the code that powers refreshes
  • Finally, reading the PR that added refreshes to Turbo is worthwhile if you want to understand how the underlying tools are implemented in JavaScript to make the magic happen