class LikesController < ApplicationController
before_action :set_post
def create
@like = current_user.likes.create!(post: @post)
respond(liked: true)
rescue ActiveRecord::RecordNotUnique
respond(liked: true)
end
def destroy
current_user.likes.where(post: @post).destroy_all
respond(liked: false)
end
private
def set_post
@post = Post.find(params[:post_id])
end
def respond(liked:)
@liked = liked
@count = @post.likes.count
respond_to do |format|
format.turbo_stream
format.html { redirect_to @post }
end
end
end
<%= turbo_stream.replace dom_id(@post, :like_button) do %>
<%= render "likes/like_button", post: @post, liked: @liked %>
<% end %>
<%= turbo_stream.update dom_id(@post, :like_count) do %>
<%= t("posts.likes", count: @count) %>
<% end %>
<%= tag.div id: dom_id(post, :like_button), data: { controller: "like-button" } do %>
<% if liked %>
<%= button_to post_like_path(post), method: :delete,
form: { data: { turbo_stream: true } },
class: "btn btn-liked",
aria: { pressed: true },
data: { like_button_target: "button", action: "like-button#toggle" } do %>
♥ Liked
<% end %>
<% else %>
<%= button_to post_likes_path(post),
form: { data: { turbo_stream: true } },
class: "btn btn-like",
aria: { pressed: false },
data: { like_button_target: "button", action: "like-button#toggle" } do %>
♡ Like
<% end %>
<% end %>
<% end %>
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["button"]
toggle() {
const pressed = this.buttonTarget.getAttribute("aria-pressed") === "true"
// Optimistic flip; the incoming Turbo Stream will overwrite this markup.
this.buttonTarget.setAttribute("aria-pressed", String(!pressed))
this.buttonTarget.classList.toggle("btn-liked", !pressed)
this.buttonTarget.classList.toggle("btn-like", pressed)
this.lock()
}
lock() {
this.buttonTarget.disabled = true
clearTimeout(this.timer)
this.timer = setTimeout(() => {
if (this.hasButtonTarget) this.buttonTarget.disabled = false
}, 1500)
}
disconnect() {
clearTimeout(this.timer)
}
}
This snippet demonstrates the Turbo Streams pattern for updating multiple, physically separate parts of a page from a single server response without writing any custom JavaScript for the DOM manipulation. The scenario is a classic "like" feature: clicking the button must flip the button's own state (from "Like" to "Liked") and independently update a counter that lives elsewhere in the layout. A single HTTP round trip produces a stream that carries both fragments.
The LikesController handles both create and destroy, toggling the association through a Like join model and rescuing ActiveRecord::RecordNotUnique so a double click can never blow up on the unique index. Rather than redirecting, the controller responds to format.turbo_stream, which Rails maps to a .turbo_stream.erb template. The HTML fallback in format.html keeps the feature usable when Turbo is disabled — progressive enhancement rather than a hard dependency.
In create.turbo_stream.erb, two turbo_stream actions are emitted in one response. The first replaces the DOM element whose id matches dom_id(@post, :like_button), and the second updates the counter element by id. Because each action targets a stable id, the button and the counter can sit anywhere in the document; Turbo finds them and swaps them in place. The destroy template mirrors this so the un-like path is symmetric.
The shared _like_button partial is the single source of truth for the button markup. It is rendered by the full page, by the stream, and could be rendered by a broadcast, so the state rendered on first load and the state rendered after a click are guaranteed to be identical. The wrapping id from dom_id is what lets replace target it precisely.
A key trade-off is that the server owns the rendering, which keeps state consistent but adds a round trip per click. The LikeButton controller layers optimistic UI on top: toggle immediately flips aria-pressed and disables the element so rapid clicks don't stack requests, then Turbo's incoming stream overwrites that guess with the authoritative markup. Reaching for this pattern makes sense whenever one action must reconcile several disconnected regions of a page cheaply and reliably.
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.