class ProductsController < ApplicationController
before_action :set_product, only: %i[edit update]
def index
@products = Product.order(:name)
end
def edit
end
def update
if @product.update(product_params)
render partial: "products/product", locals: { product: @product }
else
render partial: "products/form",
locals: { product: @product },
status: :unprocessable_entity
end
end
private
def set_product
@product = Product.find(params[:id])
end
def product_params
params.require(:product).permit(:name, :sku, :price_cents)
end
end
<h1>Products</h1>
<table class="products">
<thead>
<tr>
<th>Name</th>
<th>SKU</th>
<th>Price</th>
<th></th>
</tr>
</thead>
<tbody>
<%= render partial: "products/product", collection: @products %>
</tbody>
</table>
<%= turbo_frame_tag dom_id(product), tag_name: "tr", class: "product-row" do %>
<td><%= product.name %></td>
<td><%= product.sku %></td>
<td><%= number_to_currency(product.price_cents / 100.0) %></td>
<td>
<%= link_to "Edit", edit_product_path(product) %>
</td>
<% end %>
<%= turbo_frame_tag dom_id(product), tag_name: "tr", class: "product-row editing" do %>
<%= form_with model: product do |f| %>
<td><%= f.text_field :name %></td>
<td><%= f.text_field :sku %></td>
<td><%= f.number_field :price_cents %></td>
<td>
<%= f.submit "Save" %>
<%= link_to "Cancel", product_path(product) %>
</td>
<% if product.errors.any? %>
<td class="errors">
<%= product.errors.full_messages.to_sentence %>
</td>
<% end %>
<% end %>
<% end %>
This snippet shows the canonical Hotwire pattern for editing a single table row in place without any custom JavaScript. The key idea is that each row is wrapped in its own <turbo-frame> whose ID is unique to the record, so a link inside the frame navigates only that frame. Turbo intercepts the click, fetches the response, extracts the frame with the matching ID, and swaps just that fragment — leaving the rest of the table untouched.
The ProductsController is deliberately thin. edit renders the frame in its editing state, and update decides what to send back: on success it re-renders the read-only row partial, and on validation failure it re-renders the edit form with status: :unprocessable_entity. Turbo only processes a 422 response into a frame swap because a normal 200 on a failed form would otherwise be discarded, so returning the right status code is what makes inline validation errors appear correctly inside the frame.
The shared frame identity is the load-bearing detail. _product.html.erb and _form.html.erb both call the same dom_id(product) helper via turbo_frame_tag, producing an ID like product_42. Because the show partial and the edit partial declare the same frame, Turbo knows they are two states of one region. The Edit link targets its enclosing frame implicitly, and the form's response (whichever partial it renders) is matched back into that frame by ID.
A subtle point is that the form partial wraps only the row's cells, keeping the <tr>/<td> structure valid — Turbo replaces the frame's inner HTML, so the frame must sit inside the table in a way that still parses. The Cancel link simply re-requests the edit-less show state by pointing back at the product, restoring the read-only view.
The pattern scales well: many rows can be edited independently and concurrently, each swap is small, and it degrades gracefully — without Turbo, the links and form still perform full-page navigations to the same controller actions. It avoids the complexity of client-side state while giving an SPA-like feel, at the cost of a network round trip per interaction.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<form data-controller="query-sync" data-action="change->query-sync#apply">
<select name="status" class="rounded border p-2">
<option value="">Any</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
Filter UI that syncs query params via Stimulus (no front-end router)
Share this code
Here's the card — post it anywhere.