Making Tables Work with Turbo
June 23, 2025
Since Hotwire replaced jQuery as Rails' default frontend tooling (you will not be missed), I've used it on plenty of projects. It fits Rails' philosophy perfectly: hit the ground running, reach a working product quickly. But step outside the happy path, and you hit uncharted territory fast.
Last week I wanted to build a dynamic table with Turbo: add rows, edit in place, delete as needed. Simple functionality that should be straightforward with Turbo. And it is, mostly. But there are quirks that can eat up hours if you don't know about them.
Here's how to build Turbo tables that actually work, plus the gotchas I wish I'd known upfront.
Hitting the wall
You might be thinking: What do you mean quirks? I’ve done this myself a thousand times and it works fine. And you’d be right. This is such a common Turbo pattern that the official handbook uses a nearly identical example.
But here’s where things get interesting. Let’s start with the standard approach and see where the problems emerge.
We’ll build a simple article index with a table that updates automatically when you add new records. Adding an article should automatically update the table to show the newly created record as the first row. I’ll skip the Tailwind styling to focus on the functionality:
class ArticlesController < ApplicationController
def index
@articles = Article.all
end
def create
@article = Article.new(params.expect(article: %w[name author]))
if @article.save
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.prepend(
:articles_table,
partial: 'article_row',
locals: { article: @article }
)
end
end
else
render :new, status: :unprocessable_entity
end
end
end
<h1>Articles</h1>
<%= turbo_frame_tag :new_article do %>
<%= link_to "New Article", new_article_path %>
<% end %>
<table>
<thead>
<tr>
<th>Name</th>
<th>Author</th>
</tr>
</thead>
<tbody>
<%= turbo_frame_tag :articles_table do %>
<% @articles.each do |article| %>
<%= render "article_row", article: article %>
<% end %>
<% end %>
</tbody>
</table>
<%= turbo_frame_tag :new_article do %>
<%= render "form", article: @article %>
<% end %>
When we add a new article, we would expect it to get appended to our articles_table frame, so the first row of the table. Instead it renders outside of it. Why?

The funny thing is turbo is technically working fine, since it finds the frame and prepends the row. The problem is the frame is rendered outside of the table structure. This happens because Turbo wraps the content inside a turbo frame with the turbo-frame tag, which makes the table render incorrectly.
<turbo-frame id="articles_table">...</turbo-frame>
<table>...</table>
If we refresh we can see that it renders correctly.

Let’s fix that shall we?
Fixing our table
In order for the table to render properly and for Turbo to know which item it has to modify we have to work with direct IDs and not the turbo-frame tags.
<h1>Articles</h1>
<%= turbo_frame_tag :new_article do %>
<%= link_to "New Article", new_article_path %>
<% end %>
<table>
<thead>
<tr>
<th>Name</th>
<th>Author</th>
</tr>
</thead>
<tbody id="articles_table">
<% @articles.each do |article| %>
<%= render "article_row", article: article %>
<% end %>
</tbody>
</table>
So by removing the turbo frame and adding the articles_table id directly to the tbody tag it works properly.

Editing in-place
Editing the row in-place has the same problem with turbo frames. The solution is to use direct IDs the same way we did for the table, but for each row. To do that we will use the dom_id Rails helper which will autogenerate a unique ID for each row based on the record we pass it.
We want to replace the table row with the edit form, once we update the record, replace the row back to the normal table row. To achieve this we will modify the edit and update actions to handle the turbo stream requests.
class ArticlesController < ApplicationController
def update
article = Article.find(params[:id])
if article.update(article_params)
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
article,
partial: 'article_row',
locals: { article: @article }
)
end
end
else
redirect_to :index
end
end
def edit
article = Article.find(params[:id])
respond_to do |format|
format.html { render :edit }
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
article,
partial: 'article_row_edit',
locals: { article: }
)
end
end
end
end
<tr id="<%= dom_id(article) %>">
<td><%= article.name %></td>
<td><%= article.author %></td>
<td><%= link_to "Edit", edit_article_path(article, format: :turbo_stream) %></td>
</tr>
<tr>
<%= form_with model: article do |form| %>
<td><%= form.text_field :name, placeholder: "Name" %></td>
<td><%= form.text_field :author, placeholder: "Author" %></td>
<td class="actions">
<%= form.submit "Save" %>
<%= link_to "Cancel", articles_path %>
</td>
<% end %>
</tr>

Sadly this won’t work. When we click Save nothing happens because again the form gets rendered incorrectly. The form is not inside the row tag, so the submit action is not triggered
<form>...</form>
<tr>...</tr>
To fix this we have to use html5 remote form capabilities, so we link the input fields outside the form to it.
<tr id="<%= dom_id(article) %>">
<% form_id = "#{dom_id(article)}_form" %>
<%= form_with model: article, local: false, html: { id: form_id } do |form| %>
<%= hidden_field_tag :_method, :patch, form: form_id %>
<td><%= form.text_field :name, placeholder: "Name", form: form_id %></td>
<td><%= form.text_field :author, placeholder: "Author", form: form_id %></td>
<td class="actions">
<%= form.submit "Save", form: form_id %>
<%= link_to "Cancel", articles_path, class: "btn btn-sm" %>
</td>
<% end %>
</tr>
Once we have the inputs mapped to the form directly, the submit works as expected and we have our in-place editing!
Here’s a full preview of the dynamic behavior we achieved on our table:
These approaches should help you avoid the quirks I found when combining tables with Turbo. If you found this useful, you can find more articles here!