import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["bar", "percent", "status"]
static values = {
current: { type: Number, default: 0 },
total: { type: Number, default: 100 }
}
connect() {
this.updateProgress()
}
currentValueChanged() {
this.updateProgress()
}
updateProgress() {
const percent = Math.round((this.currentValue / this.totalValue) * 100)
this.barTarget.style.width = `${percent}%`
this.percentTarget.textContent = `${percent}%`
if (percent >= 100) {
this.complete()
}
}
setProgress(event) {
const { current, total, status } = event.detail
this.currentValue = current
this.totalValue = total
if (status && this.hasStatusTarget) {
this.statusTarget.textContent = status
}
}
complete() {
if (this.hasStatusTarget) {
this.statusTarget.textContent = 'Complete!'
}
// Dispatch completion event
this.element.dispatchEvent(new CustomEvent('progress:complete'))
}
}
<%= turbo_stream_from "upload_progress_#{current_user.id}" %>
<div class="upload-form">
<h1>Upload File</h1>
<%= form_with model: @upload,
data: { controller: "upload" } do |f| %>
<%= f.file_field :file,
data: {
upload_target: "input",
action: "change->upload#start"
} %>
<div id="upload_progress"
class="hidden mt-4"
data-controller="progress">
<div class="mb-2 flex justify-between text-sm">
<span data-progress-target="status">Uploading...</span>
<span data-progress-target="percent">0%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div data-progress-target="bar"
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
style="width: 0%"></div>
</div>
</div>
<% end %>
</div>
class ProcessUploadWorker
include Sidekiq::Worker
def perform(upload_id, user_id)
upload = Upload.find(upload_id)
total_steps = 5
(1..total_steps).each do |step|
# Broadcast progress
broadcast_progress(user_id, step, total_steps, "Processing step #{step}/#{total_steps}")
# Simulate work
sleep 1
# Actual processing would happen here
end
upload.update!(status: 'completed', processed_at: Time.current)
broadcast_progress(user_id, total_steps, total_steps, "Complete!")
end
private
def broadcast_progress(user_id, current, total, status)
Turbo::StreamsChannel.broadcast_update_to(
"upload_progress_#{user_id}",
target: "upload_progress",
partial: "uploads/progress",
locals: { current: current, total: total, status: status }
)
end
end
Users need feedback during slow operations like file uploads or complex processing. I combine Turbo Streams with background jobs to show real-time progress. When an operation starts, I enqueue a job that periodically broadcasts progress updates via Action Cable. The frontend subscribes to a user-specific channel and updates a progress bar as messages arrive. For file uploads, I use ActiveStorage's direct upload with progress events. The key is providing meaningful progress—showing percentage when possible, or indeterminate spinners when progress can't be measured. I also show estimated time remaining and allow cancellation when feasible. Clear progress indicators reduce perceived latency and abandonment rates.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
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
<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)
class TasksController < ApplicationController
def destroy
@task = Task.find(params[:id])
@task.destroy!
respond_to do |format|
Remove deleted items instantly with turbo_stream.remove
import { Controller } from "@hotwired/stimulus"
import Mousetrap from "mousetrap"
export default class extends Controller {
connect() {
// Global shortcuts
Keyboard shortcuts with Stimulus and Mousetrap
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
Share this code
Here's the card — post it anywhere.