ruby erb 78 lines · 4 tabs

Turbo Stream form errors: replace only the form frame

Shared by codesnips Jan 2026
4 tabs
class CommentsController < ApplicationController
  before_action :set_post

  def new
    @comment = @post.comments.build
  end

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

    if @comment.save
      respond_to do |format|
        format.turbo_stream
        format.html { redirect_to @post }
      end
    else
      # 422 is required: Turbo only re-renders non-GET responses on 4xx/5xx.
      respond_to do |format|
        format.html { render :new, status: :unprocessable_entity }
        format.turbo_stream do
          render :new, status: :unprocessable_entity
        end
      end
    end
  end

  private

  def set_post
    @post = Post.find(params[:post_id])
  end

  def comment_params
    params.require(:comment).permit(:author, :body)
  end
end
4 files · ruby, erb Explain with highlit

This snippet shows the canonical Hotwire pattern for handling server-side validation errors without a full page reload: when a create action fails, only the <turbo-frame> wrapping the form is replaced, so error messages and the user's typed input reappear in place while the rest of the page stays untouched.

The key detail lives in CommentsController. On the failure branch, the action responds with status: :unprocessable_entity (HTTP 422). This is not incidental — Turbo Drive and Turbo Frames deliberately ignore successful 200 responses to non-GET requests when deciding whether to render a form again, but they do render the response body on a 4xx/5xx. Returning 422 is what makes the invalid form re-render at all; a plain render :new with an implicit 200 would silently do nothing on the client. The success branch instead responds with turbo_stream, appending the new comment and resetting the form.

In new.html.erb the form is wrapped in a turbo_frame_tag whose id matches the frame Turbo will look for. Because the form posts from inside this frame, Turbo scopes the response to that frame automatically. The _form.html.erb partial is the reusable unit: it renders comment.errors and repopulates fields from the (invalid, in-memory) @comment object, so nothing the user typed is lost.

The trade-off worth understanding is scope. Frame-based error rendering only replaces one region, which keeps flash areas, counters, and unrelated components intact — but it also means the error UI must live entirely inside the frame. If validation errors need to update elements outside the frame (a header badge, a global alert), a multi-target turbo_stream response is the better tool. This frame approach shines for self-contained forms where the failure state is local.

A common pitfall is forgetting the 422 status, which produces a form that appears to do nothing on submit. Another is placing the turbo_frame_tag id inconsistently between new and the error render — the ids must match exactly or Turbo drops the response. Keeping the form in a shared partial, as here, guarantees the markup and frame id stay aligned across both code paths.


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.

Turbo Stream form errors: replace only the form frame — share card
Link copied