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
<%= turbo_frame_tag "new_comment" do %>
<div class="comment-form">
<h3>Leave a comment</h3>
<%= render "form", post: @post, comment: @comment %>
</div>
<% end %>
<%= form_with model: [post, comment], id: "comment_form" do |f| %>
<% if comment.errors.any? %>
<div class="form-errors" role="alert">
<p><%= pluralize(comment.errors.count, "error") %> prevented this comment from being saved:</p>
<ul>
<% comment.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="field">
<%= f.label :author %>
<%= f.text_field :author, value: comment.author %>
</div>
<div class="field">
<%= f.label :body %>
<%= f.text_area :body, rows: 4 %>
</div>
<%= f.submit "Post comment" %>
<% end %>
<%= turbo_stream.append "comments" do %>
<%= render partial: "comments/comment", locals: { comment: @comment } %>
<% end %>
<%= turbo_stream.replace "new_comment" do %>
<%= turbo_frame_tag "new_comment" do %>
<div class="comment-form">
<h3>Leave a comment</h3>
<%= render "form", post: @post, comment: @post.comments.build %>
</div>
<% end %>
<% end %>
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
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.