Async Actions Need Feedback (no dead buttons) | LlamaPress Cookbook
LlamaPress
Cookbook
Forms Stable

Async Actions Need Feedback (no dead buttons)

Any button that triggers non-instant work must show immediate in-flight feedback — disable + spinner, optimistic UI, and a clear result (and failure) toast.

Proven on bh-exterior-marketing.leo.llamapress.ai 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)

<%# 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-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.