<%= turbo_stream_from :comments %>
<section class="comments">
<h2>Discussion</h2>
<%= turbo_frame_tag "new_comment" do %>
<%= render "comments/form", comment: Comment.new(post: @post) %>
<% end %>
<div id="comments">
<%= render partial: "comments/comment", collection: @comments %>
</div>
</section>
<%= form_with model: [comment.post, comment], id: "new_comment" do |f| %>
<% if comment.errors.any? %>
<div class="form-errors">
<%= comment.errors.full_messages.to_sentence %>
</div>
<% end %>
<div class="field">
<%= f.label :body, "Add a comment" %>
<%= f.text_area :body, rows: 3, autofocus: comment.errors.any? %>
</div>
<%= f.submit "Post", class: "btn btn-primary" %>
<% end %>
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
respond_to do |format|
if @comment.save
format.turbo_stream
format.html { redirect_to @post, notice: "Comment posted." }
else
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
"new_comment",
partial: "comments/form",
locals: { comment: @comment }
), status: :unprocessable_entity
end
format.html { render "posts/show", status: :unprocessable_entity }
end
end
end
private
def set_post
@post = Post.find(params[:post_id])
end
def comment_params
params.require(:comment).permit(:body)
end
end
<%= turbo_stream.prepend "comments" do %>
<%= render partial: "comments/comment", locals: { comment: @comment } %>
<% end %>
<%= turbo_stream.replace "new_comment" do %>
<%= render "comments/form", comment: Comment.new(post: @post) %>
<% end %>
This snippet shows the canonical Hotwire pattern for an inline create form that instantly prepends a freshly created record to the top of a list without a full page reload. The three tabs cooperate: the list template establishes the DOM contract with stable IDs, the controller responds to Turbo Stream requests, and the create template describes the two surgical DOM mutations the browser should apply.
In comments/index.html.erb the list is wrapped in a container whose id is comments, matching the dom_id Turbo Streams will target. Each comment is rendered by a partial (comments/_comment.html.erb, referenced implicitly) so the same markup is reused on first paint and on later stream updates — reuse is what keeps the initial HTML and the streamed HTML identical. The turbo_stream_from :comments line subscribes the page to a broadcast channel, but the interesting flow here is the direct response, not broadcasting. The form is built with form_with model: and no local:/data-turbo overrides, so Turbo submits it as a Turbo Stream request by sending an Accept: text/vnd.turbo-stream+html header.
In CommentsController the create action saves the record and then branches on respond_to. The format.turbo_stream branch is the key: Rails automatically looks for create.turbo_stream.erb, so no explicit render is needed on the happy path. The else clause re-renders the form partial with status: :unprocessable_entity inside a stream so validation errors replace the form in place rather than reloading the page. The format.html fallback keeps the endpoint usable without JavaScript, which matters for progressive enhancement and crawlers.
In create.turbo_stream.erb two actions run in sequence. turbo_stream.prepend "comments" inserts the new comment partial at the top of the list, giving the newest-first ordering users expect. turbo_stream.replace "new_comment" swaps the form for a fresh, empty one so the user can immediately post again and any stale error markup is cleared. Targeting by stable DOM id is what makes this robust: the ids in the template must match the stream targets exactly, or the mutation silently does nothing. This approach is ideal when a single client just posted and expects immediate feedback; pair it with broadcast_prepend_to when other connected clients also need the update.
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.