<%= tag.div id: dom_id(comment), class: "comment", data: { controller: "comment" } do %>
<div class="comment__body">
<%= comment.body %>
</div>
<footer class="comment__meta">
<span><%= comment.author_name %></span>
<%= tag.span id: dom_id(comment, :votes), class: "comment__votes" do %>
<%= pluralize(comment.votes_count, "vote") %>
<% end %>
<%= button_to "Upvote",
upvote_comment_path(comment),
method: :post,
form: { data: { turbo_stream: true } } %>
</footer>
<% end %>
class CommentsController < ApplicationController
before_action :set_comment, only: %i[update upvote]
def create
@comment = @post.comments.create!(comment_params)
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.append(
"comments",
partial: "comments/comment",
locals: { comment: @comment }
)
end
format.html { redirect_to @post }
end
end
def update
@comment.update!(comment_params)
render turbo_stream: turbo_stream.replace(
@comment,
partial: "comments/comment",
locals: { comment: @comment }
)
end
def upvote
@comment.increment!(:votes_count)
presenter = CommentPresenter.new(@comment)
render turbo_stream: turbo_stream.update(
presenter.votes_dom_id,
pluralize(@comment.votes_count, "vote")
)
end
private
def set_comment
@comment = Comment.find(params[:id])
end
def comment_params
params.require(:comment).permit(:body)
end
end
class CommentPresenter
include ActionView::RecordIdentifier
attr_reader :comment
def initialize(comment)
@comment = comment
end
def container_dom_id
dom_id(comment)
end
def votes_dom_id
dom_id(comment, :votes)
end
end
class Comment < ApplicationRecord
belongs_to :post
after_create_commit :broadcast_created
after_update_commit :broadcast_updated
def author_name
author&.name || "Anonymous"
end
private
def broadcast_created
broadcast_append_to(
post,
target: "comments",
partial: "comments/comment",
locals: { comment: self }
)
end
def broadcast_updated
broadcast_replace_to(
post,
partial: "comments/comment",
locals: { comment: self }
)
end
end
Turbo replaces DOM nodes by matching the id attribute in an incoming <turbo-stream> against an element already on the page. If the server and the client disagree about that id — even by a hyphen — the replacement silently does nothing, and stale content lingers. The fix is to never hand-write those ids: let dom_id generate them everywhere so the same record always maps to the same string.
The _comment partial tab shows the foundation. The wrapping element uses dom_id(comment), which yields something like comment_42, and the vote counter uses dom_id(comment, :votes), producing votes_comment_42. Because dom_id derives the string purely from the record's class and to_key, any code path that references the same record computes the identical id. The partial never invents an id by hand, so there is a single source of truth.
The CommentsController tab renders Turbo Stream responses. In create, turbo_stream.append targets the literal id "comments" — the stable container — and appends the comment partial. In update, turbo_stream.replace(@comment, ...) accepts the record directly and internally calls dom_id(@comment) to compute the target, guaranteeing it matches the id the partial rendered earlier. The controller never types an id string, which is exactly what keeps the round trip deterministic.
The CommentPresenter tab centralizes derived ids like the votes counter through votes_dom_id, so background jobs, mailers, and views all agree without duplicating the dom_id(comment, :votes) call. The Comment broadcast tab closes the loop: broadcast_replace_to reuses the same partial and the same dom_id, so a WebSocket-driven update and an HTTP form submission produce byte-identical target ids.
The key trade-off is discipline over convenience — every template, stream, and broadcast must funnel through the helper rather than string interpolation. The payoff is that renaming a model, adding STI, or changing primary keys shifts every id in lockstep. A common pitfall is mixing a hand-written id="comment-#{id}" in one place with dom_id elsewhere; the two differ by punctuation and Turbo quietly drops the update. Reach for this pattern whenever the same record is targeted from more than one place.
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.