module TurboResponder
extend ActiveSupport::Concern
private
def respond_with_turbo(resource, on_success:, partial:, notice: nil)
respond_to do |format|
if resource.errors.any?
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
view_context.dom_id(resource, :form),
partial: partial,
locals: { resource_key => resource }
), status: :unprocessable_entity
end
format.html { render on_success == :index ? :new : :edit, status: :unprocessable_entity }
else
flash.now[:notice] = notice if notice
format.turbo_stream { render turbo_stream: build_turbo_streams(resource, partial, notice) }
format.html { redirect_to resource_path(resource, on_success), notice: notice }
end
end
end
def build_turbo_streams(resource, partial, notice)
collection_id = resource.class.model_name.plural
streams = []
if resource.previously_new_record?
streams << turbo_stream.prepend(collection_id, partial: partial, locals: { resource_key => resource })
else
streams << turbo_stream.replace(view_context.dom_id(resource), partial: partial, locals: { resource_key => resource })
end
streams << turbo_flash(notice) if notice
streams
end
def turbo_flash(message)
turbo_stream.update("flash", partial: "shared/flash", locals: { message: message })
end
def resource_key
controller_name.singularize.to_sym
end
def resource_path(resource, on_success)
on_success == :index ? url_for(controller: controller_name, action: :index) : resource
end
end
class CommentsController < ApplicationController
include TurboResponder
before_action :set_post
before_action :set_comment, only: %i[update destroy]
def create
@comment = @post.comments.build(comment_params)
@comment.save
respond_with_turbo(@comment, on_success: :index, partial: "comments/comment", notice: "Comment posted.")
end
def update
@comment.update(comment_params)
respond_with_turbo(@comment, on_success: :index, partial: "comments/comment", notice: "Comment updated.")
end
def destroy
@comment.destroy
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
turbo_stream.remove(@comment),
turbo_flash("Comment removed.")
]
end
format.html { redirect_to @post, notice: "Comment removed." }
end
end
private
def set_post
@post = Post.find(params[:post_id])
end
def set_comment
@comment = @post.comments.find(params[:id])
end
def comment_params
params.require(:comment).permit(:body)
end
end
<%# app/views/comments/_comment.html.erb %>
<%= tag.div id: dom_id(comment), class: "comment" do %>
<p><%= comment.body %></p>
<%= link_to "Edit", edit_post_comment_path(comment.post, comment) %>
<%= button_to "Delete", [comment.post, comment], method: :delete, form: { data: { turbo_confirm: "Sure?" } } %>
<% end %>
<%# app/views/comments/_form.html.erb %>
<%= form_with model: [comment.post, comment], id: dom_id(comment, :form) do |f| %>
<% if comment.errors.any? %>
<ul class="errors">
<% comment.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
<% end %>
<%= f.text_area :body %>
<%= f.submit %>
<% end %>
<%# app/views/shared/_flash.html.erb %>
<%= tag.div id: "flash" do %>
<% if local_assigns[:message] %>
<p class="notice"><%= message %></p>
<% end %>
<% end %>
A common friction point with Hotwire is that every controller action ends up repeating the same respond_to do |format| dance: render a Turbo Stream when the request came from a turbo_frame/stream-aware form, otherwise fall back to a full HTML redirect. This snippet extracts that pattern into a small mixin so actions stay declarative and both response formats are handled consistently.
In TurboResponder concern, respond_with_turbo accepts a resource, an on_success action name, and a partial. The key idea is that a well-behaved endpoint should degrade gracefully: clients that speak turbo_stream get a surgical DOM update, while plain HTML clients (no JS, a shared link, a crawler) still get a working redirect. The concern centralizes the flash message and the branching so individual actions don't reinvent it. turbo_flash renders a shared shared/flash partial via turbo_stream.update, which keeps notification handling identical everywhere. Note the explicit format.html { redirect_to ... } fallback — omitting it is the classic bug that leaves non-Turbo requests with a blank 204 or a missing template error.
build_turbo_streams returns an array of turbo_stream actions. It uses prepend/replace depending on whether the record was just created, and always appends the flash update, so the caller only supplies intent, not markup wiring. Building an array and passing it to render turbo_stream: is idiomatic and lets multiple targets update in one response.
CommentsController shows the payoff: create and update each become a couple of lines. On validation failure it re-renders the form partial with status: :unprocessable_entity, which is what Turbo needs to replace the form and display inline errors instead of silently ignoring the response. The dom_id-based targeting (comments, comment_#{id}) matches the view's element IDs.
The trade-off is a little indirection — the concern hides the exact streams being sent, so debugging means reading build_turbo_streams. It also assumes a fairly uniform CRUD shape; highly custom actions may still want a hand-written respond_to. The views tab shows the matching partials and dom_id conventions the concern depends on. This pattern is worth reaching for once three or more controllers share the same create/update/destroy Turbo flow.
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.