class Comment < ApplicationRecord
belongs_to :post
belongs_to :author, class_name: "User"
validates :body, presence: true, length: { maximum: 2_000 }
default_scope { order(created_at: :asc) }
after_create_commit do
broadcast_prepend_later_to(
[post, :comments],
target: "comments",
partial: "comments/comment",
locals: { comment: self }
)
end
after_update_commit do
broadcast_replace_later_to([post, :comments], target: self)
end
after_destroy_commit do
broadcast_remove_to([post, :comments], target: self)
end
end
class CommentsController < ApplicationController
before_action :authenticate_user!
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
@comment.author = current_user
if @comment.save
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
"new_comment",
partial: "comments/form",
locals: { post: @post, comment: @post.comments.build }
)
end
format.html { redirect_to @post }
end
else
render turbo_stream: turbo_stream.replace(
"new_comment",
partial: "comments/form",
locals: { post: @post, comment: @comment }
), status: :unprocessable_entity
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_from @post, :comments %>
<article class="post">
<h1><%= @post.title %></h1>
<div class="post-body"><%= @post.body %></div>
</article>
<section class="comments">
<h2>Comments (<%= @post.comments.size %>)</h2>
<%= turbo_frame_tag "new_comment" do %>
<%= render "comments/form", post: @post, comment: @post.comments.build %>
<% end %>
<div id="comments">
<%= render @post.comments %>
</div>
</section>
<%= turbo_frame_tag dom_id(comment) do %>
<div class="comment" id="<%= dom_id(comment) %>">
<div class="comment-meta">
<strong><%= comment.author.display_name %></strong>
<time datetime="<%= comment.created_at.iso8601 %>">
<%= time_ago_in_words(comment.created_at) %> ago
</time>
</div>
<p class="comment-body"><%= comment.body %></p>
<% if comment.author == current_user %>
<%= button_to "Delete",
[comment.post, comment],
method: :delete,
form: { data: { turbo_confirm: "Delete this comment?" } },
class: "link-danger" %>
<% end %>
</div>
<% end %>
This snippet shows how a live comment feed is kept in sync across every connected browser using Turbo Streams and Action Cable, without any custom JavaScript for the append logic. The core idea is that the server owns the DOM diff: instead of returning JSON and reconstructing markup on the client, Rails renders a partial server-side and pushes a turbo_stream frame over a WebSocket to a named stream that clients subscribe to.
In Comment model, the broadcasts_to macro wires the model into a stream keyed by its parent post. Every create, update, and destroy automatically triggers a broadcast to [post, :comments], so no controller code is needed for the happy path. The custom after_create_commit callback overrides the default create behaviour to broadcast_prepend_later_to, which enqueues the render on a background job so the request returns immediately and the socket write happens off the request thread. broadcast_prepend_later_to is preferred over the synchronous variant because rendering ERB and pushing to Redis under an HTTP request adds latency and can fail independently of the write.
The target: of "comments" matches the DOM id of the container the stream mutates, and partial: plus locals: control exactly what HTML each subscriber receives. Because rendering happens in a job, locals must be serializable-friendly — passing the record and letting the partial resolve associations is the safe pattern.
In CommentsController, create responds to both turbo_stream and html formats. The turbo_stream branch is essentially a fallback: it updates the DOM of the submitting user immediately via the HTTP response, while broadcasts_to handles every other connected client. Resetting the form with turbo_stream.replace gives the submitter a cleared input.
In comments/show.html.erb, turbo_stream_from post, :comments opens the subscription — this helper renders the <turbo-cable-stream-source> element that Action Cable binds to. The container id must equal the broadcast target. A key pitfall is a mismatch between the stream name in the view and the model; both must resolve to the same signed stream identifier or broadcasts silently go nowhere. This pattern trades a little rendering cost on the server for zero client-side view logic and guaranteed markup consistency.
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.