Building a sortable table with Turbo 8's page refreshes
21 Mar 2024Scroll-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:
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:
Next use faker to generate test data so we can see our sort mechanism working. From your terminal:
And then hop into the rails console with bin/rails c
and run this code to generate test data in your local database:
Finish setup by creating a basic table structure on the players index page at app/views/players/index.html.erb
:
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 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:
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
:
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:
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:
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:
And then update app/views/layouts/application.html.erb
to yield
the head
contentprovided
by turbo_refreshes_with
:
You can add turbo_refreshes_with
directly into the <head>
in app/views/layouts/application.html.erb
if you prefer:
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:
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:
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:
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:
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:
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:
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
:
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