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