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:

func runAction() {
    Task {
        await action()
    }
}

It solves the fact that a Button action is sync, but now lifecycle becomes the ViewModel’s responsibility:

So cancellation and view lifecycle details leak into the ViewModel.

2) Create Task in the view itself

You can also do:

Button("Run action") {
    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("Run action") {
    // 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:

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("Run action") {
            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:

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.