class Comment < ApplicationRecord
belongs_to :article
belongs_to :author, class_name: "User"
validates :body, presence: true, length: { maximum: 2_000 }
after_create_commit :broadcast_created
after_destroy_commit :broadcast_removed
private
def broadcast_created
broadcast_append_to(
article,
target: "comments",
partial: "comments/comment",
locals: { comment: self }
)
end
def broadcast_removed
broadcast_remove_to(article, target: dom_id(self))
end
end
# Equivalent sugar for the two callbacks above:
# broadcasts_to ->(comment) { comment.article }, inserts_by: :append
<%# locals: (comment:) %>
<div id="<%= dom_id(comment) %>" class="comment">
<div class="comment__meta">
<span class="comment__author"><%= comment.author.name %></span>
<time datetime="<%= comment.created_at.iso8601 %>">
<%= time_ago_in_words(comment.created_at) %> ago
</time>
</div>
<p class="comment__body"><%= simple_format(comment.body) %></p>
<% if comment.author == Current.user %>
<%= button_to "Delete",
article_comment_path(comment.article, comment),
method: :delete,
class: "comment__delete",
form: { data: { turbo_confirm: "Delete this comment?" } } %>
<% end %>
</div>
<article class="article">
<h1><%= @article.title %></h1>
<%= sanitize @article.body_html %>
</article>
<%= turbo_stream_from @article %>
<section class="comments">
<h2>Comments (<%= @article.comments.size %>)</h2>
<div id="comments">
<%= render partial: "comments/comment",
collection: @article.comments.includes(:author),
as: :comment %>
</div>
<%= turbo_frame_tag "new_comment" do %>
<%= form_with model: [@article, Comment.new] do |f| %>
<%= f.text_area :body, placeholder: "Add a comment\u2026", rows: 3 %>
<%= f.submit "Post" %>
<% end %>
<% end %>
</section>
class CommentsController < ApplicationController
before_action :set_article
def create
@comment = @article.comments.build(comment_params)
@comment.author = Current.user
if @comment.save
# The model broadcast fans out to every other subscriber;
# this response just resets the form for the submitter.
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
"new_comment",
partial: "comments/form",
locals: { article: @article, comment: Comment.new }
)
end
format.html { redirect_to @article }
end
else
render "articles/show", status: :unprocessable_entity
end
end
def destroy
comment = @article.comments.find(params[:id])
comment.destroy! if comment.author == Current.user
head :no_content
end
private
def set_article
@article = Article.find(params[:article_id])
end
def comment_params
params.require(:comment).permit(:body)
end
end
This snippet shows the canonical Hotwire way to build a live comment feed in Rails: the model broadcasts DOM updates over Action Cable, and the view subscribes to a named stream. No custom JavaScript is required because Turbo Streams carry the rendered HTML fragment over the WebSocket and apply it to the page.
In Comment model, the association callbacks after_create_commit and after_destroy_commit fire the broadcasts. broadcast_append_to targets the stream named by the parent article and appends the freshly rendered comment partial into the comments DOM id. The commit-suffixed callbacks matter: broadcasting after_create (before commit) would race the transaction and could push HTML for a row that isn't yet visible to the subscriber's query, or roll back entirely. broadcast_remove_to sends a targeted removal that Turbo matches by DOM id, which is why the partial wraps each comment in dom_id(comment). The broadcasts_to one-liner is the sugar version that infers append-on-create and remove-on-destroy, shown as an alternative.
The _comment partial renders a single comment with dom_id(comment) as its element id. This is the contract that makes streams work: the same partial renders both the initial server-side list and the fragments pushed later, so appended and removed nodes line up exactly with what is already on the page.
In articles/show view, turbo_stream_from @article opens the subscription. It renders a <turbo-cable-stream-source> element whose signed stream name must match the target passed to broadcast_append_to. Passing the model object keeps both sides in sync via to_gid. The comments container is the append anchor, and the existing comments are rendered through the shared partial.
CommentsController stays deliberately thin. Because the model broadcasts, the controller only needs to persist the record and respond to the direct requester; every other connected client receives the update through the channel. The create action redirects normally for the submitter while broadcasts fan out to everyone else, so a single code path serves both real-time and full-page navigation. The trade-off is that broadcasting from the model couples persistence to a view partial, and every save incurs a render even when no one is watching; for high-write models a background broadcast_*_later job is preferable.
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.