<%# Subscribe every viewer of this post to its broadcasts %>
<%= turbo_stream_from @post %>
<%# Update the acting user's button to reflect the toggle %>
<%= turbo_stream.replace dom_id(@post, :like_button) do %>
<%= render "posts/like_button", post: @post %>
<% end %>
class Post < ApplicationRecord
has_many :likes, dependent: :destroy
has_many :likers, through: :likes, source: :user
after_update_commit :broadcast_like_count, if: :saved_change_to_likes_count?
def liked_by?(user)
return false unless user
likes.exists?(user_id: user.id)
end
private
def broadcast_like_count
broadcast_replace_to(
self,
target: ActionView::RecordIdentifier.dom_id(self, :likes),
partial: "posts/like_count",
locals: { post: self }
)
end
end
class LikesController < ApplicationController
before_action :authenticate_user!
before_action :set_post
def create
# counter_cache on Like keeps posts.likes_count atomic
@post.likes.find_or_create_by(user: current_user)
respond_with_button
end
def destroy
@post.likes.where(user: current_user).destroy_all
respond_with_button
end
private
def set_post
@post = Post.find(params[:post_id])
end
def respond_with_button
@post.reload
respond_to do |format|
format.turbo_stream
format.html { redirect_to @post }
end
end
end
<%# locals: post %>
<div id="<%= dom_id(post, :like_button) %>" class="like-button">
<% if post.liked_by?(current_user) %>
<%= button_to post_like_path(post),
method: :delete, form: { data: { turbo_stream: true } },
class: "btn liked" do %>
♥ Liked
<% end %>
<% else %>
<%= button_to post_likes_path(post),
method: :post, form: { data: { turbo_stream: true } },
class: "btn" do %>
♡ Like
<% end %>
<% end %>
<%= render "posts/like_count", post: post %>
</div>
<%# posts/_like_count.html.erb %>
<span id="<%= dom_id(post, :likes) %>" class="like-count">
<%= pluralize(post.likes_count, "like") %>
</span>
This snippet shows how a live like counter is built in Rails with Hotwire's Turbo Streams so that every connected client sees the count change the moment anyone clicks, without any custom JavaScript. The story spans four collaborating files: the Post model that broadcasts changes, the LikesController that toggles a like, the _like_button partial that renders the DOM target, and the like.turbo_stream.erb template that responds to the acting user.
In Post model, has_many :likes combines with counter_cache: true on the Like side so the likes_count column is maintained by the database association callbacks rather than a COUNT(*) on every render. The key detail is that increment_counter/decrement_counter (used by the counter cache) issue atomic UPDATE ... SET likes_count = likes_count + 1 statements, which avoids the read-modify-write race where two simultaneous likes would each read the same stale value and clobber each other. broadcast_replace_to is triggered from an after_update_commit callback keyed on saved_change_to_likes_count?, so a Turbo Stream frame is pushed only when the number actually changed, and only after the transaction commits.
In LikesController, the toggle is made idempotent per user with find_or_create_by/destroy_by guarded against duplicates, and the whole operation is wrapped so the counter stays consistent. Because the model already broadcasts to every subscriber, the controller's own respond_to only needs to update the button state for the person who clicked.
_like_button partial wraps the count in a turbo_frame-style target dom_id(post, :likes), which is exactly the id broadcast_replace_to targets, so the server-rendered fragment and the broadcast frame line up automatically. like.turbo_stream.erb uses turbo_stream.replace to swap the button so the current user sees the liked/unliked toggle immediately.
The main trade-off is that broadcast_replace_to re-renders the partial server-side for each broadcast, which is cheap for a counter but should be scoped narrowly. A common pitfall is broadcasting on every save rather than on the guarded saved_change_to_likes_count?, which floods clients with redundant frames. This pattern is the right reach when many viewers watch the same record and eventual real-time consistency matters more than per-request control.
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.