Making Tables Work with Turbo | Guillermo Aguirre

Ask questions Research chat →

https://www.guillermoaguirre.dev/articles/making-tables-work-with-turbo · scraped

rails

Attachments

Scraped Content

— 948 words · 2026-05-19 12:39:25 UTC ·

Excerpt

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 emer
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: ```plain text 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 endend ``` ```plain text <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> ``` ```plain text <%= 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? ![](https://prod-files-secure.s3.us-west-2.amazonaws.com/871f1661-80b8-4d0c-ac3b-2adfc6ff4c66/d83ef585-8a03-419c-b3bc-025da88d8cdc/image.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB466QRHGMAUH%2F20260519%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20260519T193925Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEBMaCXVzLXdlc3QtMiJIMEYCIQD0x99Ca5WfaPk%2BTCAZhG1I3CyTKY5NE8w%2B4RnBwN2gIAIhAOuBXApIeyQyPRNxSngM0yDSR9i5NHoHA2CzduGSXnujKogECNz%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEQABoMNjM3NDIzMTgzODA1IgxRZFzo3kLxc2CK1Ggq3AN1LlII%2FgzOnuBavE6NcPJxx%2FbgbchA3yEvX27m3x4LTKZF6Bbw7zCHsSuvVtmYLMKzqeuLTdWtvyWvjDmGWo0M7c%2FsjevWgzKZNfFe6j1uE2RcuNNjO7CZ23f21YQe%2Fm94T1XfwjMklQsd8RnHtcXzArOsjCvzTkWW62CZ8dHKylR3pSNYPBymVIwPLiTe2pyW71F3rI%2BO%2FB6eZ%2B9rfZD67P6s%2FglnJarCQlzEzHjp9v%2FQJjfal9AhjeUOX7B1KgVyYM%2B68R7%2FZXf1uHSxIq%2FbovLHGsSIiHpzi0NggAAB94HGeE%2BklVUM%2FSxlKqWfkSn1F79wg%2B0vPr30s469ObN3WmKjSZvNrqEouICSoBMR2n2Amjltfs0ekagKPM5h182M9Tqyx56%2FaNzy8%2BChdhCNqZW%2FY7O02DhldlZXdzx2NjG95tY8FCqJUyXI3myywaclCo0oLoi50bUBsbDDMk83Q7nFagCLHaYcZKYC7kV6mCYLIhquV7Q3ly%2B32dbAMqdrvTv1F4lBg09LZaD5AQl6GJsgJQFs7QQO6BCTve7hgzMSLS6glLljMrBgcLlVOBI%2BZlvezP%2BIc2mf2eIqHTdDeP9Y4x%2FUhfNSsyOE2zGL3lHxogVskpsoxdtyKzDG2rLQBjqkATJibCu7rQkCcbM5JfWBVxf6HBa03i4xb7EpeprMFaEWPhRp9jOJY7JCSd3Z8LQA6OBoSPu2KeC19MEXwgPPiXc4ns3S%2FKdqKexx0Rnm%2Br0ZM6WTWr%2BoMhN0Tc1Hphhw3U4N2XR%2BIGuoHUj7LY4GcKFtHzYhg8%2BE%2BhrpSDAxsle8lS%2F7ZFztC6XDxTg5dcWZYSXk7bB9p7pPDTl9Qh20ZUPIG6QO&X-Amz-Signature=cb4fd7adfbf38598142acfee4af998c9ffa5c1c301caff31a42aa5503430a83d&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject) After create 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. ```plain text <turbo-frame id="articles_table">...</turbo-frame><table>...</table> ``` If we refresh we can see that it renders correctly. ![](https://prod-files-secure.s3.us-west-2.amazonaws.com/871f1661-80b8-4d0c-ac3b-2adfc6ff4c66/cf4dde64-af68-4a01-addb-83d0216db103/image.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB466QRHGMAUH%2F20260519%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20260519T193925Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEBMaCXVzLXdlc3QtMiJIMEYCIQD0x99Ca5WfaPk%2BTCAZhG1I3CyTKY5NE8w%2B4RnBwN2gIAIhAOuBXApIeyQyPRNxSngM0yDSR9i5NHoHA2CzduGSXnujKogECNz%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEQABoMNjM3NDIzMTgzODA1IgxRZFzo3kLxc2CK1Ggq3AN1LlII%2FgzOnuBavE6NcPJxx%2FbgbchA3yEvX27m3x4LTKZF6Bbw7zCHsSuvVtmYLMKzqeuLTdWtvyWvjDmGWo0M7c%2FsjevWgzKZNfFe6j1uE2RcuNNjO7CZ23f21YQe%2Fm94T1XfwjMklQsd8RnHtcXzArOsjCvzTkWW62CZ8dHKylR3pSNYPBymVIwPLiTe2pyW71F3rI%2BO%2FB6eZ%2B9rfZD67P6s%2FglnJarCQlzEzHjp9v%2FQJjfal9AhjeUOX7B1KgVyYM%2B68R7%2FZXf1uHSxIq%2FbovLHGsSIiHpzi0NggAAB94HGeE%2BklVUM%2FSxlKqWfkSn1F79wg%2B0vPr30s469ObN3WmKjSZvNrqEouICSoBMR2n2Amjltfs0ekagKPM5h182M9Tqyx56%2FaNzy8%2BChdhCNqZW%2FY7O02DhldlZXdzx2NjG95tY8FCqJUyXI3myywaclCo0oLoi50bUBsbDDMk83Q7nFagCLHaYcZKYC7kV6mCYLIhquV7Q3ly%2B32dbAMqdrvTv1F4lBg09LZaD5AQl6GJsgJQFs7QQO6BCTve7hgzMSLS6glLljMrBgcLlVOBI%2BZlvezP%2BIc2mf2eIqHTdDeP9Y4x%2FUhfNSsyOE2zGL3lHxogVskpsoxdtyKzDG2rLQBjqkATJibCu7rQkCcbM5JfWBVxf6HBa03i4xb7EpeprMFaEWPhRp9jOJY7JCSd3Z8LQA6OBoSPu2KeC19MEXwgPPiXc4ns3S%2FKdqKexx0Rnm%2Br0ZM6WTWr%2BoMhN0Tc1Hphhw3U4N2XR%2BIGuoHUj7LY4GcKFtHzYhg8%2BE%2BhrpSDAxsle8lS%2F7ZFztC6XDxTg5dcWZYSXk7bB9p7pPDTl9Qh20ZUPIG6QO&X-Amz-Signature=3dbc8e1ff1937ea61fe95134d5c16dc409b659ba086f254f1cd02f1a6960d85e&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject) After refresh 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. ```plain text <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. ![](https://prod-files-secure.s3.us-west-2.amazonaws.com/871f1661-80b8-4d0c-ac3b-2adfc6ff4c66/6553d274-fda4-4e43-991a-e0c9918866d1/image.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB466QRHGMAUH%2F20260519%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20260519T193925Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEBMaCXVzLXdlc3QtMiJIMEYCIQD0x99Ca5WfaPk%2BTCAZhG1I3CyTKY5NE8w%2B4RnBwN2gIAIhAOuBXApIeyQyPRNxSngM0yDSR9i5NHoHA2CzduGSXnujKogECNz%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEQABoMNjM3NDIzMTgzODA1IgxRZFzo3kLxc2CK1Ggq3AN1LlII%2FgzOnuBavE6NcPJxx%2FbgbchA3yEvX27m3x4LTKZF6Bbw7zCHsSuvVtmYLMKzqeuLTdWtvyWvjDmGWo0M7c%2FsjevWgzKZNfFe6j1uE2RcuNNjO7CZ23f21YQe%2Fm94T1XfwjMklQsd8RnHtcXzArOsjCvzTkWW62CZ8dHKylR3pSNYPBymVIwPLiTe2pyW71F3rI%2BO%2FB6eZ%2B9rfZD67P6s%2FglnJarCQlzEzHjp9v%2FQJjfal9AhjeUOX7B1KgVyYM%2B68R7%2FZXf1uHSxIq%2FbovLHGsSIiHpzi0NggAAB94HGeE%2BklVUM%2FSxlKqWfkSn1F79wg%2B0vPr30s469ObN3WmKjSZvNrqEouICSoBMR2n2Amjltfs0ekagKPM5h182M9Tqyx56%2FaNzy8%2BChdhCNqZW%2FY7O02DhldlZXdzx2NjG95tY8FCqJUyXI3myywaclCo0oLoi50bUBsbDDMk83Q7nFagCLHaYcZKYC7kV6mCYLIhquV7Q3ly%2B32dbAMqdrvTv1F4lBg09LZaD5AQl6GJsgJQFs7QQO6BCTve7hgzMSLS6glLljMrBgcLlVOBI%2BZlvezP%2BIc2mf2eIqHTdDeP9Y4x%2FUhfNSsyOE2zGL3lHxogVskpsoxdtyKzDG2rLQBjqkATJibCu7rQkCcbM5JfWBVxf6HBa03i4xb7EpeprMFaEWPhRp9jOJY7JCSd3Z8LQA6OBoSPu2KeC19MEXwgPPiXc4ns3S%2FKdqKexx0Rnm%2Br0ZM6WTWr%2BoMhN0Tc1Hphhw3U4N2XR%2BIGuoHUj7LY4GcKFtHzYhg8%2BE%2BhrpSDAxsle8lS%2F7ZFztC6XDxTg5dcWZYSXk7bB9p7pPDTl9Qh20ZUPIG6QO&X-Amz-Signature=85916de963c44f1ebd31a9cc7ee6a6f7f063bb6bd265bacd4efec0dcc36c5a0f&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject) # 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. ```plain text 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 endend ``` ```plain text <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> ``` ```plain text <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> ``` ![](https://prod-files-secure.s3.us-west-2.amazonaws.com/871f1661-80b8-4d0c-ac3b-2adfc6ff4c66/14b683f7-ee4e-42f0-9b67-998bc89ade83/image.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAZI2LB466QRHGMAUH%2F20260519%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20260519T193925Z&X-Amz-Expires=3600&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEBMaCXVzLXdlc3QtMiJIMEYCIQD0x99Ca5WfaPk%2BTCAZhG1I3CyTKY5NE8w%2B4RnBwN2gIAIhAOuBXApIeyQyPRNxSngM0yDSR9i5NHoHA2CzduGSXnujKogECNz%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEQABoMNjM3NDIzMTgzODA1IgxRZFzo3kLxc2CK1Ggq3AN1LlII%2FgzOnuBavE6NcPJxx%2FbgbchA3yEvX27m3x4LTKZF6Bbw7zCHsSuvVtmYLMKzqeuLTdWtvyWvjDmGWo0M7c%2FsjevWgzKZNfFe6j1uE2RcuNNjO7CZ23f21YQe%2Fm94T1XfwjMklQsd8RnHtcXzArOsjCvzTkWW62CZ8dHKylR3pSNYPBymVIwPLiTe2pyW71F3rI%2BO%2FB6eZ%2B9rfZD67P6s%2FglnJarCQlzEzHjp9v%2FQJjfal9AhjeUOX7B1KgVyYM%2B68R7%2FZXf1uHSxIq%2FbovLHGsSIiHpzi0NggAAB94HGeE%2BklVUM%2FSxlKqWfkSn1F79wg%2B0vPr30s469ObN3WmKjSZvNrqEouICSoBMR2n2Amjltfs0ekagKPM5h182M9Tqyx56%2FaNzy8%2BChdhCNqZW%2FY7O02DhldlZXdzx2NjG95tY8FCqJUyXI3myywaclCo0oLoi50bUBsbDDMk83Q7nFagCLHaYcZKYC7kV6mCYLIhquV7Q3ly%2B32dbAMqdrvTv1F4lBg09LZaD5AQl6GJsgJQFs7QQO6BCTve7hgzMSLS6glLljMrBgcLlVOBI%2BZlvezP%2BIc2mf2eIqHTdDeP9Y4x%2FUhfNSsyOE2zGL3lHxogVskpsoxdtyKzDG2rLQBjqkATJibCu7rQkCcbM5JfWBVxf6HBa03i4xb7EpeprMFaEWPhRp9jOJY7JCSd3Z8LQA6OBoSPu2KeC19MEXwgPPiXc4ns3S%2FKdqKexx0Rnm%2Br0ZM6WTWr%2BoMhN0Tc1Hphhw3U4N2XR%2BIGuoHUj7LY4GcKFtHzYhg8%2BE%2BhrpSDAxsle8lS%2F7ZFztC6XDxTg5dcWZYSXk7bB9p7pPDTl9Qh20ZUPIG6QO&X-Amz-Signature=7ca37aca7d99332d2f4ebee50d134443cbe4b3811aaae58cd230553ead060cd2&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject) 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 To fix this we have to use html5 remote form capabilities, so we link the input fields outside the form to it. ```plain text <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!

Visibility

Visible to everyone

Reading Status

Related Bookmarks

My Note


Saved!

Annotations

Export as Markdown
+ Annotate selection

Add Annotation