import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["count", "button"]
static values = {
url: String,
liked: Boolean
}
async toggle(event) {
event.preventDefault()
// Optimistic update
const previousLiked = this.likedValue
const previousCount = parseInt(this.countTarget.textContent)
this.likedValue = !previousLiked
this.countTarget.textContent = previousCount + (this.likedValue ? 1 : -1)
this.updateButtonState()
try {
const response = await fetch(this.urlValue, {
method: this.likedValue ? 'POST' : 'DELETE',
headers: {
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content,
'Accept': 'application/json'
}
})
if (!response.ok) {
throw new Error('Request failed')
}
const data = await response.json()
// Update with actual count from server
this.countTarget.textContent = data.likes_count
} catch (error) {
// Rollback on error
this.likedValue = previousLiked
this.countTarget.textContent = previousCount
this.updateButtonState()
alert('Failed to update like. Please try again.')
}
}
updateButtonState() {
if (this.likedValue) {
this.buttonTarget.classList.add('liked')
this.buttonTarget.setAttribute('aria-pressed', 'true')
} else {
this.buttonTarget.classList.remove('liked')
this.buttonTarget.setAttribute('aria-pressed', 'false')
}
}
}
<article class="post-card">
<h2><%= link_to post.title, post_path(post) %></h2>
<p><%= post.excerpt %></p>
<div class="post-actions"
data-controller="like"
data-like-url-value="<%= post_like_path(post) %>"
data-like-liked-value="<%= current_user&.liked?(post) %>">
<button type="button"
class="like-button <%= 'liked' if current_user&.liked?(post) %>"
data-like-target="button"
data-action="like#toggle"
aria-pressed="<%= current_user&.liked?(post) %>">
<i class="fas fa-heart"></i>
<span data-like-target="count"><%= post.likes_count %></span>
</button>
</div>
</article>
class LikesController < ApplicationController
before_action :authenticate_user!
def create
@post = Post.find(params[:post_id])
@like = @post.likes.create(user: current_user)
render json: { likes_count: @post.likes.count }
end
def destroy
@post = Post.find(params[:post_id])
@post.likes.where(user: current_user).destroy_all
render json: { likes_count: @post.likes.count }
end
end
Waiting for server confirmation makes interfaces feel sluggish. Optimistic updates immediately show the expected result, then reconcile with the server response. When a user likes a post, I increment the count immediately via Stimulus, submit the request in the background, and handle rollback if it fails. For create operations, I append a pending item with a loading state, then replace it with the real item when the server responds. This pattern requires careful state management—I track pending operations and ensure idempotency so duplicate clicks don't create chaos. The perceived performance improvement is dramatic, but I'm conservative about which operations get optimistic treatment.