ruby erb 110 lines · 4 tabs

Live comments with model broadcasts + turbo_stream_from

Shared by codesnips Jan 2026
4 tabs
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
4 files · ruby, erb Explain with highlit

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

ruby
class CommentsController < ApplicationController
  before_action :set_post

  def create
    @comment = @post.comments.build(comment_params)

System test: asserting Turbo Stream responses

rails hotwire turbo
by codesnips 4 tabs
ruby
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

rails activerecord patterns
by Alex Kumar 1 tab
ruby
module Api
  module V1
    class UsersController < BaseController
      def show
        user = User.includes(:profile).find(params[:id])

ETags for conditional requests and caching

rails caching http-caching
by Alex Kumar 1 tab
ruby
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

rails turbo hotwire
by codesnips 4 tabs
ruby
require "csv"

class PeopleCsvStream
  include Enumerable

  HEADERS = %w[id full_name email signed_up_at plan].freeze

Resilient CSV Export as a Streamed Response

rails performance streaming
by codesnips 3 tabs
erb
<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)

rails hotwire stimulus
by Henry Kim 2 tabs

Share this code

Here's the card — post it anywhere.

Live comments with model broadcasts + turbo_stream_from — share card
Link copied