<button
data-controller="optimistic-toggle"
data-optimistic-toggle-url-value="<%= post_star_path(post) %>"
data-optimistic-toggle-on-text-value="Starred"
data-optimistic-toggle-off-text-value="Star"
data-optimistic-toggle-on-class-value="bg-yellow-400"
data-optimistic-toggle-off-class-value="bg-gray-200"
class="rounded px-3 py-1"
type="button">
<%= current_member.starred?(post) ? 'Starred' : 'Star' %>
</button>
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
url: String,
onText: String,
offText: String,
onClass: String,
offClass: String
}
connect() {
this.on = this.element.textContent.trim() === this.onTextValue
}
async click() {
const previous = this.on
this.toggle()
try {
const res = await fetch(this.urlValue, {
method: "POST",
headers: {
"X-CSRF-Token": document.querySelector("meta[name=csrf-token]").content,
"Accept": "text/vnd.turbo-stream.html"
}
})
if (!res.ok) throw new Error(`Bad response: ${res.status}`)
} catch (e) {
this.on = previous
this.render()
}
}
toggle() {
this.on = !this.on
this.render()
}
render() {
this.element.textContent = this.on ? this.onTextValue : this.offTextValue
this.element.classList.toggle(this.onClassValue, this.on)
this.element.classList.toggle(this.offClassValue, !this.on)
}
}
I like optimistic UI for tiny interactions (like “star” or “follow”) because it makes the interface feel instant. The tradeoff is handling failure cleanly. I implement this with Stimulus: flip CSS + text immediately, then submit a Turbo request in the background via fetch including the CSRF token. If the response isn’t 2xx, revert the UI to its previous state. This avoids writing a full SPA state layer while still delivering good UX. The server remains the source of truth, and the button can still be progressively enhanced: if JS is off, it’s just a normal button_to.