<%# locals: item %>
<button
type="button"
class="toggle-btn"
data-controller="toggle"
data-action="click->toggle#toggle"
data-toggle-favorited-value="<%= current_user.favorited?(item) %>"
data-toggle-url-value="<%= favorites_path(item_id: item.id) %>"
data-toggle-destroy-url-value="<%= favorite_path(item) %>"
aria-pressed="<%= current_user.favorited?(item) %>">
<span data-toggle-target="icon" aria-hidden="true">
<%= current_user.favorited?(item) ? "★" : "☆" %>
</span>
<span data-toggle-target="label">
<%= current_user.favorited?(item) ? "Saved" : "Save" %>
</span>
</button>
<div class="visually-hidden" aria-live="polite" data-toggle-target="status"></div>
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["icon", "label", "status"]
static values = {
favorited: Boolean,
url: String,
destroyUrl: String
}
favoritedValueChanged() {
this.render()
}
async toggle() {
const previous = this.favoritedValue
this.favoritedValue = !previous
this.element.disabled = true
const goingOn = this.favoritedValue
const url = goingOn ? this.urlValue : this.destroyUrlValue
const method = goingOn ? "POST" : "DELETE"
try {
const response = await fetch(url, {
method,
headers: {
"X-CSRF-Token": this.csrfToken,
"Accept": "application/json"
}
})
if (!response.ok) throw new Error(`Request failed: ${response.status}`)
this.announce(goingOn ? "Saved" : "Removed")
} catch (error) {
this.favoritedValue = previous // revert on failure
this.announce("Could not update, please try again")
} finally {
this.element.disabled = false
}
}
render() {
const on = this.favoritedValue
this.iconTarget.textContent = on ? "★" : "☆"
this.labelTarget.textContent = on ? "Saved" : "Save"
this.element.setAttribute("aria-pressed", on)
}
announce(message) {
this.statusTarget.textContent = message
}
get csrfToken() {
const meta = document.querySelector("meta[name='csrf-token']")
return meta ? meta.content : ""
}
}
class FavoritesController < ApplicationController
before_action :authenticate_user!
before_action :set_item, only: :create
def create
favorite = current_user.favorites.find_or_create_by!(item: @item)
render json: { favorited: true, id: favorite.id }, status: :created
end
def destroy
favorite = current_user.favorites.find_by(item_id: params[:id])
favorite&.destroy
render json: { favorited: false }, status: :ok
end
private
def set_item
@item = Item.find(params[:item_id])
end
end
This snippet shows an optimistic UI toggle — a favourite/bookmark button — built with Rails and Stimulus. The core idea of optimistic UI is to update the interface immediately on click, assuming the server request will succeed, and only roll back if it actually fails. This makes the button feel instant instead of waiting for a network round trip, which matters for high-frequency actions like starring items in a list.
The _toggle.html.erb partial renders the button and wires it to the Stimulus controller through data-controller and data-action. The current server truth is embedded in data-toggle-favorited-value so the controller starts with correct state, and the endpoint plus HTTP method come from data attributes rather than being hardcoded in JavaScript, keeping the controller reusable across any toggle. The CSRF token is pulled from the meta tag so the fetch call passes Rails' forgery protection.
In the toggle_controller.js, the favorited value is a Stimulus typed value, so declaring favoritedValueChanged gives a callback that fires whenever the value flips — this is where render() centralises all DOM updates. On toggle(), the controller flips the value optimistically and disables the button to prevent double-submits, then issues the fetch. The crucial part is the failure path: the original state is captured in previous before the flip, and if the response is not ok or the network throws, favoritedValue is restored, which re-runs render() and visually reverts the button. A small announce() writes to an aria-live region so screen readers hear both success and failure.
The FavoritesController is deliberately idempotent: create finds-or-creates the join record and destroy removes it, each responding with JSON the client ignores on success but checks via response.ok. Because the server is the source of truth, a revert simply means trusting the last-known-good state. The main trade-off is that optimistic UI can briefly show a lie; the mitigation is fast, honest reverts plus disabling the control mid-flight so concurrent clicks cannot desync the state. This pattern suits low-risk, reversible actions and is a poor fit for irreversible or money-moving operations where confirmation should precede the visual change.
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
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
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
Share this code
Here's the card — post it anywhere.