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.