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.