SwiftUI Concurrency Pattern: Bind Async Tasks to View State
3 minutes read •
Starting async work in SwiftUI looks easy. But very quickly, lifecycle problems appear.
If the user leaves the screen, what should happen to the task? If they tap the same button twice, should the previous work be canceled? Where should cancellation logic live?
I wanted a plain solution: clear lifecycle behavior, small code, and no custom abstraction.
The problem
There are a few common ways to start async work in SwiftUI. They all work, but each one has a cost.
1) Start from a synchronous ViewModel function, create a Task inside
This is the most common pattern:
}
It solves the fact that a Button action is sync, but now lifecycle becomes the ViewModel’s responsibility:
- keep a reference to the newly created
Task - cancel it at the right time
- avoid duplicated in-flight work
So cancellation and view lifecycle details leak into the ViewModel.
2) Create Task in the view itself
You can also do:
Button() {
Task {
await viewModel.runAction()
}
}
But this brings back the same lifecycle problem. You still need to decide when and where to cancel.
Swift by Sundell’s AsyncButton
Swift by Sundell explained this problem well with an AsyncButton abstraction.
The main benefit is clear: async behavior is wrapped in a reusable API, and cancellation is hidden from call sites.
Still, the core mechanism depends on manual cancel. It is cleaner, but still manual.
A plain state-driven solution with .task(id:)
My preferred base solution is simple: make state transitions explicit, then bind side effects with .task(id:).
ViewModel state:
@Published var operationState: OperationState = .idle
Trigger from UI (important part):
Button() {
// Because state is Hashable, we can ignore guarding against multiple taps here. The task will only run once per state change.
viewModel.operationState = .running
}
Note
There is a small mindset shift here:
A) Have an async function, call it, manage its lifecycle manually.
B) Have a state, update the state, react to the state to start work, then update the state again when work finishes.
In this article, we use option B.
View:
// task(id:) makes sure the closure is called only when state changes
.task(id: viewModel.operationState) {
if viewModel.operationState == .running {
await viewModel.runAction()
viewModel.operationState = .idle
}
}
Why this works:
- the task is attached to the view lifecycle
- when the view disappears, SwiftUI cancels the task automatically
- state drives effects, so intent is explicit (
.idle -> .running -> .idle) - no need to keep a
Taskhandle in the ViewModel for this flow
This keeps the ViewModel focused on state and business logic, while SwiftUI owns the task lifecycle.
Loading indicator only if work takes long
Another important point from the AsyncButton discussion is UX: often we want a loading indicator only if the operation is not very fast.
A nice way to do it is to combine state-driven rendering with delayed animation, without using sleep APIs:
private var isPerformingTask: Bool {
viewModel.operationState == .running
}
private var actionRow: some View {
HStack {
Button() {
viewModel.operationState = .running
}
.task(id: viewModel.operationState) {
if viewModel.operationState == .running {
await viewModel.runAction()
viewModel.operationState = .idle
}
}
if isPerformingTask {
ProgressView()
.progressViewStyle(.circular)
.transition(.opacity)
}
}
.animation(.linear.delay(0.15), value: isPerformingTask)
}
That delay(0.15) helps a lot:
- quick operations finish before the spinner appears
- longer than 0.15 seconds operations still show progress feedback
- no artificial delay in business logic
- no
Task.sleepcoordination code
Simple, explicit, and easy to reason about.
Final note
This is a plain solution, not a framework.
If needed, you can hide it later behind custom API (AsyncButton, view modifier, custom control). But starting with plain state + .task(id:) keeps behavior obvious and lifecycle-safe from day one.