Async Actions Need Feedback (no dead buttons)
⚠️ Cookbook example — not live code. (KEEP THIS CALLOUT.) Every code block below is an example snippet, not part of the llamapress.ai codebase, and not running on this server. This is a reference recipe for a Leo instance (an AI coding agent) to implement in its own app — read it to understand the pattern, then recreate it there.
When a user clicks a button that does real work — "Sync", "Import", "Generate", "Send",
"Refresh from <API>" — the worst possible experience is nothing happening. A plain
form/button POST shows no change while the server works, so a multi-second action reads as
"broken." Users re-click (firing it twice), or give up and assume it failed. The fix is to
make the in-flight state visible and immediate, and to make both success and failure
loud.
When to use: any action that isn't instant — especially remote API calls, imports, report generation, anything that paginates or runs a job. When not to: trivial same-page toggles that update optimistically already.
This pattern came from a real failure (SupportIncident #80, category
ux_observation): a "Sync from Jobber" button ran silently for several seconds, then only a flash toast ("20 created, 0 updated") appeared. Users couldn't tell it was working.
The 80/20 in one breath
- On click, immediately disable the trigger and switch its label/icon to a working
state ("Syncing…" + spinner). The cheapest version is Rails' built-in
data: { disable_with: "Syncing…" }(or Turbo'saria-busy). - For anything slower than ~1s or remote, run it as a background job instead of blocking the request, so the page stays responsive.
- Stream progress back (Turbo Stream / ActionCable): "Fetching page 2…", "120 of ~300 synced".
- End with a result toast that includes counts, and re-enable the button.
- Make failure equally loud — "Sync failed:
<reason>— Reconnect", never a silent no-op.
The minimum acceptable bar is step 1 + step 4. Steps 2–3 are the high-quality version.
Layer 1 — The View (minimum bar: built-in disable_with)
<%# app/views/leads/index.html.erb %>
<%# Rails auto-disables the button and swaps its text while the request is in flight. %>
<%= button_to "Sync from Jobber",
sync_jobber_leads_path,
method: :post,
class: "btn btn-primary",
data: { turbo_submits_with: "Syncing…", disable_with: "Syncing…" } %>
That one-liner already kills the "dead button" feeling. Everything below is the upgrade to a real progress experience for slow/remote work.
Layer 2 — The View (quality bar: Stimulus trigger + live status region)
<%# app/views/leads/index.html.erb %>
<div data-controller="async-action">
<%= button_to "Sync from Jobber",
sync_jobber_leads_path,
method: :post,
form: { data: { async_action_target: "form", turbo_stream: true } },
class: "btn btn-primary",
data: { async_action_target: "button",
action: "submit->async-action#start" } %>
<%# The server streams progress + the final summary into this region. %>
<%= turbo_frame_tag "jobber_sync_status", class: "ml-3 text-sm text-slate-500" %>
</div>
Layer 3 — Stimulus / JavaScript (instant in-flight affordance)
// app/javascript/controllers/async_action_controller.js
import { Controller } from "@hotwired/stimulus"
// Gives an action button an immediate, visible "working" state the moment it's
// clicked — independent of how long the server/job takes.
export default class extends Controller {
static targets = ["button"]
start() {
const btn = this.buttonTarget
if (btn.dataset.busy === "1") return // guard against double-fire
btn.dataset.busy = "1"
btn.dataset.originalText = btn.innerHTML
btn.disabled = true
btn.setAttribute("aria-busy", "true")
btn.innerHTML =
`<span class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent align-[-2px]"></span> Syncing…`
}
// Call from a Turbo Stream the server broadcasts when the job finishes.
reset() {
const btn = this.buttonTarget
btn.disabled = false
btn.removeAttribute("aria-busy")
btn.dataset.busy = "0"
if (btn.dataset.originalText) btn.innerHTML = btn.dataset.originalText
}
}
Layer 4 — Controller (don't block the request; report at the end)
# app/controllers/leads_controller.rb
def sync_jobber
# Hand slow/remote work to a job so the request returns instantly and the page
# stays interactive. The job streams progress + the final toast back.
JobberSyncJob.perform_later(current_user.id,
start_date: params[:start_date],
end_date: params[:end_date])
respond_to do |format|
format.turbo_stream # render a "Started…" frame immediately
format.html { redirect_to leads_path, notice: "Sync started — you'll see results here." }
end
end
# app/jobs/jobber_sync_job.rb (sketch — the point is progress + loud success/failure)
class JobberSyncJob < ApplicationJob
def perform(user_id, start_date:, end_date:)
user = User.find(user_id)
result = Leads::JobberSyncService.new(user).sync(start_date:, end_date:)
msg = if result[:success]
r = result[:results]
"Jobber sync complete: #{r[:created]} created, #{r[:updated]} updated" \
"#{" (#{r[:errors]} errors)" if r[:errors].to_i > 0}"
else
"Jobber sync FAILED: #{result[:error]} — reconnect Jobber and try again."
end
Turbo::StreamsChannel.broadcast_replace_to(
"leads:#{user_id}",
target: "jobber_sync_status",
html: ApplicationController.render(partial: "leads/sync_status", locals: { message: msg, ok: result[:success] })
)
end
end
Gotchas (the hard-won stuff)
- Re-entrancy / double-submit. A slow button gets clicked twice. Guard it (the
data-busyflag above, ordisable_with) or you'll fire the action twice. - Failure must be as visible as success. The original incident's real damage was a silent failure that still looked connected. Always render the error path — never a no-op. (See the related lesson: an API connection UI must show real health, not just "token present.")
- Don't conflate "started" with "done." If you background the work, the immediate response is "started"; the result toast comes later over the stream. Word them differently so the user isn't told "complete" before it is.
- Turbo vs non-Turbo. Some pages disable Turbo (
data-turbo="false");disable_withstill works, butturbo_submits_withwon't — provide the Stimulus fallback. - Always include counts in the result ("20 created, 0 updated") — a bare "Done" leaves users unsure anything changed.
Files this pattern touches
app/views/leads/index.html.erb # the trigger + status region
app/views/leads/_sync_status.html.erb # the streamed result/failure partial
app/javascript/controllers/async_action_controller.js
app/controllers/leads_controller.rb # enqueue, don't block
app/jobs/jobber_sync_job.rb # do the work + broadcast progress/result
How to adapt to your schema
- Smallest case: skip the job/stream entirely — just add
data: { disable_with: "Working…" }to the button and keep the synchronous controller action with its flash. That alone removes the "dead button." - Medium: add the Stimulus
async_actioncontroller for an instant spinner even while the synchronous request runs. - Full: move the work to a job and stream progress + a loud success/failure toast. Use this whenever the action is remote or can take more than a second or two.
- Rename
JobberSyncJob/leads:#{id}to your domain; the shape (instant affordance → background → progress → loud result) is the reusable part.