Optimistic UI updates with Turbo Streams

Jordan Lee Jan 2026
3 tabs
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')
    }
  }
}
3 files · javascript, erb, ruby Explain with highlit

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.