class Comment < ApplicationRecord
belongs_to :post
belongs_to :author, class_name: "User"
validates :body, presence: true, length: { maximum: 5_000 }
broadcasts_to ->(comment) { [comment.post, :comments] },
inserts_by: :append,
target: ->(comment) { "post_#{comment.post_id}_comments" }
scope :chronological, -> { order(created_at: :asc) }
def edited?
updated_at - created_at > 1.second
end
end
class PostsController < ApplicationController
before_action :authenticate_user!
before_action :set_post, only: %i[show]
def show
@comments = @post.comments.chronological.includes(:author)
@comment = @post.comments.new
end
def create_comment
@post = Post.find(params[:post_id])
@comment = @post.comments.create!(comment_params.merge(author: current_user))
respond_to do |format|
format.turbo_stream { head :no_content }
format.html { redirect_to @post }
end
rescue ActiveRecord::RecordInvalid => e
render turbo_stream: turbo_stream.replace(
"new_comment",
partial: "comments/form",
locals: { post: @post, comment: e.record }
), status: :unprocessable_entity
end
private
def set_post
@post = Post.find(params[:id])
end
def comment_params
params.require(:comment).permit(:body)
end
end
<%= turbo_stream_from @post, :comments %>
<article class="post">
<h1><%= @post.title %></h1>
<%= sanitize @post.rendered_body %>
</article>
<section class="comments">
<h2>Discussion (<%= @comments.size %>)</h2>
<ul id="<%= "post_#{@post.id}_comments" %>">
<%= render @comments %>
</ul>
<div id="new_comment">
<%= render "comments/form", post: @post, comment: @comment %>
</div>
</section>
<%= tag.li id: dom_id(comment), class: "comment" do %>
<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>
<% if comment.edited? %>
<span class="comment__edited">(edited)</span>
<% end %>
</div>
<p class="comment__body"><%= simple_format(comment.body) %></p>
<% end %>
This snippet shows how Rails 7 turns Active Record callbacks into live UI updates using broadcasts_to, without hand-writing any WebSocket plumbing. The pattern solves a common problem: keeping every open browser in sync when a record is created, updated, or destroyed. Instead of manually calling Turbo::StreamsChannel.broadcast_* from a controller or job, the model declares which stream it belongs to and Turbo emits the right stream action after each commit.
In Comment model, broadcasts_to :post wires three callbacks at once: an append on create, a replace on update, and a remove on destroy, all scoped to the stream named after the associated post. The ->(comment) { [comment.post, :comments] } lambda builds a compound stream identifier so each post gets its own isolated channel. Because broadcasts fire on the after-commit boundary, a rolled-back transaction never leaks a phantom update to subscribers, which is why this is safer than broadcasting from a before_save. The target: option matches the DOM id that the view renders, and Rails resolves the partial by convention (comments/_comment).
In PostsController, notice how little the controller does: @comment = @post.comments.create!(comment_params) is the entire real-time path. The controller responds to a normal HTML request; the broadcast happens as a side effect of the successful commit, so the originating user and every other subscriber receive the same stream. This keeps the request/response cycle honest and avoids duplicating rendering logic between the controller and the broadcaster.
In posts/show view, turbo_stream_from @post, :comments opens the subscription whose name matches the model's stream lambda, and the comments list is wrapped in a container whose id lines up with the target. New <li> elements are appended automatically as other users post.
The trade-offs are worth knowing: broadcasts_to renders partials inside the broadcasting process, so heavy templates can slow commits — the broadcasts_to ... later variant offloads rendering to a job. It also assumes the DOM ids and partial names follow convention, so drift between the model, view, and target will silently drop updates. For per-record fan-out with predictable naming, though, it removes an enormous amount of boilerplate.
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.