Updating HTML tables with turbo streams

If you have been using Hotwire you will know at this point that updating tables is not as straightforward as with any other HTML element.

Of course that nowadays tables are less used than a few years ago, in favor of divs with some css achieving pretty much the same results in most scenarios.

However, if you don't have the time/budget to migrate a table to a div, keep reading on how to properly make this work.

This is the simplified version of the table I have been working with:

<!-- views/users/index.html.erb -->

<table>
  <thead>
    <tr>User ID</tr>
    <tr>User Name</tr>
  </thead>
  <tbody>
    <%= render @users %>
  </tbody>
</table>

And then each user row:

<!-- views/users/_user.html.erb -->

<tr>
  <td><%= user.id %></td>
  <td><%= user.full_name %></td>
</tr>

That works just fine, and we render the list of users as expected.

Updating the table to accept turbo_stream updates

We have added the UsersController#create action and want the created user to be added to the top of the table.

In order to do so, we will do three things:

  • Update the controller to be able to respond to turbo_stream type
  • Create the turbo_stream update to prepend the user to a turbo_frame_tag
  • Add the turbo_frame_tag to the view for turbo_stream to know where to prepend the user

Let's first start by updating our controller to respond to turbo_stream :

# controllers/users_controller.rb

class UsersController < ApplicationController
  def create
    @user = User.new(user_params)

    respond_to do |format|
      if @user.save
        # [...] you may have other formats here too.
        
        format.turbo_stream
      else
        format.html { render :new, status: :unprocessable_entity }
      end
    end
  end  
end

Now create the proper turbo_stream update:

<%= turbo_stream.prepend 'users' do %>  
  <%= render @user %>  
<% end %>

What that does is prepending the rendered user row to the target users.

And now, the missing piece would be to add the turbo_frame_tag "users" to the view for turbo_stream to be able to prepend the HTML to it.

Since we want the user to be on the top of the table, we would need to prepend to a frame inside the tbody, does that make sense?

<!-- views/users/index.html.erb -->

<tbody>
  <%= turbo_frame_tag 'users' do %>
    <%= render @users %>
  <% end %>
</tbody>

Pretty simple, right? Well, not exactly. Because if you render the page, the result will look like this:

<!-- views/users/index.html -->

<turbo-frame id="users"></turbo-frame> <!-- Turbo Frame is outside the table! -->

<table>
  <thead><!-- [...] --></thead>
  <tbody><!-- [...] --></tbody>
</table>

That means if you prepend some HTML to the turbo_frame_tag "users" , it will end up being rendered before the <table>, instead of at the beginning of the <tbody>.

That happens because only <table> elements are welcome inside tables.

Making turbo_streams work with tables

Well, we will not actually make it work with tables, but what we will do instead is to use another alternative to turbo_frame_tag.

As you may know, with turbo_stream you need a target to identify the DOM element to update. Normally this would refer to the id given to the turbo_frame_tag.

But did you know that you can also update any regular DOM element?

In fact, by setting the target to users, it is not actually expecting a turbo_frame_tag at all, but any element with an ID of users.

That means we can remove the turbo_frame_tag from our view, and set the id="users" to another element, and turbo_stream will update it as expected:

<!-- views/users/index.html.erb -->

<tbody id='users'>
  <%= render @users %>
</tbody>

That's it! When a user is created, the controller will render the row and prepend it to the tbody, causing it to be at the top!

You can read the discussion on this subject here.

« Go home