---
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]
---

# 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

1. 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's `aria-busy`).
2. For anything slower than ~1s or remote, **run it as a background job** instead of
   blocking the request, so the page stays responsive.
3. **Stream progress back** (Turbo Stream / ActionCable): "Fetching page 2…",
   "120 of ~300 synced".
4. End with a **result toast that includes counts**, and re-enable the button.
5. 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)

```erb
<%# 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)

```erb
<%# 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)

```javascript
// 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)

```ruby
# 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
```

```ruby
# 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-busy` flag above, or `disable_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_with`
  still works, but `turbo_submits_with` won'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

1. **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."
2. **Medium:** add the Stimulus `async_action` controller for an instant spinner even
   while the synchronous request runs.
3. **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.
4. Rename `JobberSyncJob` / `leads:#{id}` to your domain; the shape (instant affordance →
   background → progress → loud result) is the reusable part.
