erb ruby 112 lines · 4 tabs

Use dom_id everywhere to keep Turbo replacements deterministic

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

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

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.

Use dom_id everywhere to keep Turbo replacements deterministic — share card
Link copied