Skip to content

Async Button (SwiftUI)

A SwiftUI guide for coding agents. Also covers async action button, disable button while loading, prevent double tap task, cancel running task button, button swift concurrency.

Button has no built-in Swift Concurrency support, so a bare Task in its action spawns a new task on every tap. Wrapping the pattern in an AsyncButton disables the button while its action runs and adds cancellation.

The Problem

This creates a task per press. Tap five times during a slow update and five updates run.

Button {
  Task { await heavyUpdate() }
} label: {
  Text("Increment")
}

AsyncButton

Track the running state, disable the button while true, and reset when the action finishes. The component styles like any plain button (buttonStyle, controlSize) because those flow through the environment.

struct AsyncButton<Label: View>: View {
  let action: () async -> Void
  let label: Label

  @State private var isRunning = false

  init(
    action: @escaping () async -> Void,
    @ViewBuilder label: () -> Label
  ) {
    self.action = action
    self.label = label()
  }

  var body: some View {
    Button {
      isRunning = true
      Task {
        await action()
        isRunning = false
      }
    } label: {
      label
    }
    .disabled(isRunning)
  }
}

Cancellation via a Trigger Value

Hold the Task handle and watch an equatable cancellation value with onChange; any change cancels the running task. This trigger pattern, toggling a value to fire a declarative reaction, is the same one the sensory feedback and scroll APIs use.

struct AsyncButton<Label: View, Trigger: Equatable>: View {
  var cancellation: Trigger
  let action: () async -> Void
  let label: Label

  @State private var task: Task<Void, Never>?
  @State private var isRunning = false

  var body: some View {
    Button {
      isRunning = true
      task = Task {
        await action()
        isRunning = false
      }
    } label: {
      label
    }
    .disabled(isRunning)
    .onChange(of: cancellation) {
      task?.cancel()
    }
  }
}
AsyncButton(cancellation: trigger) {
  try? await Task.sleep(for: .seconds(3))
  counter += 1
} label: {
  Text("Increment")
}

Button("Cancel") { trigger.toggle() }

The action should handle CancellationError itself; cancellation only reaches it through the cooperative check inside Task.sleep or your own Task.checkCancellation calls.

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-swiftui-guide({ topic: "swiftui-building-async-button" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems