ruby erb 118 lines · 3 tabs

A reusable respond_to block for turbo_stream + html

Shared by codesnips Jan 2026
3 tabs
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
3 files · ruby, erb Explain with highlit

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

ruby
class CommentsController < ApplicationController
  before_action :set_post

  def create
    @comment = @post.comments.build(comment_params)

System test: asserting Turbo Stream responses

rails hotwire turbo
by codesnips 4 tabs
ruby
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

rails activerecord patterns
by Alex Kumar 1 tab
ruby
module Api
  module V1
    class UsersController < BaseController
      def show
        user = User.includes(:profile).find(params[:id])

ETags for conditional requests and caching

rails caching http-caching
by Alex Kumar 1 tab
ruby
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

rails turbo hotwire
by codesnips 4 tabs
ruby
require "csv"

class PeopleCsvStream
  include Enumerable

  HEADERS = %w[id full_name email signed_up_at plan].freeze

Resilient CSV Export as a Streamed Response

rails performance streaming
by codesnips 3 tabs
erb
<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)

rails hotwire stimulus
by Henry Kim 2 tabs

Share this code

Here's the card — post it anywhere.

A reusable respond_to block for turbo_stream + html — share card
Link copied