Home

`.task` and what actually cancels it

.task is the SwiftUI modifier that starts async work tied to a view’s lifetime. It replaced a pattern involving onAppear, a stored Task, onDisappear and manual cancellation — about fifteen lines of bookkeeping that were easy to get subtly wrong.

struct ProfileView: View {
    let userID: User.ID
    @State private var profile: Profile?

    var body: some View {
        ProfileContent(profile: profile)
            .task {
                profile = try? await api.profile(for: userID)
            }
    }
}

The lifetime rule

The task starts when the view appears and is cancelled automatically when the view’s identity goes away. Not when it scrolls off screen — when its identity leaves the tree.

That distinction matters. In a List, a row scrolling out of view may or may not be removed, so a .task on a row is not a reliable “cancel when off screen”. For an infinite stream — a location feed, a websocket — bind it to a container view whose lifetime you understand rather than to a row.

The id: parameter is the important one

This is what fixed a bug I had lived with for months:

.task(id: userID) {
    profile = try? await api.profile(for: userID)
}

Without id:, the task runs once for that view identity. Navigating from user A to user B reuses the same view, so the task does not run again, and the screen shows A’s profile with B’s navigation title.

With id:, changing userID cancels the in-flight task and starts a new one. That is exactly the behaviour you want and it is tedious to write by hand — cancel the previous, start the next, check cancellation after the await so a slow response for A does not overwrite B.

Tip

Any value that the task’s work depends on belongs in id:. If the closure reads userID, filter and sortOrder, then id: should be all three — combine them into a small Hashable struct rather than picking one.

Cancellation is still cooperative

.task cancels the Task, which sets a flag. Work that never checks it keeps running:

.task(id: query) {
    let results = await search(query)      // if `search` checks cancellation, fine
    guard !Task.isCancelled else { return }   // check again before writing
    self.results = results
}

That second check matters. A cancelled task’s closure can still be executing after the await returns, and writing stale results is precisely the bug id: was supposed to fix.

Most URLSession and Task.sleep calls cooperate automatically. A synchronous loop does not.

Priority

.task(priority: .background) for work the user is not waiting on. The default is .userInitiated, which is right for anything on screen and wrong for prefetching or analytics.

.task(priority: .background) {
    await prefetchNextPage()
}

Getting this wrong in the wasteful direction — everything at .userInitiated — causes thread explosion and, on a device with efficiency cores, actively prevents the scheduler from doing its job.

The case where it does not fire

.task runs when the view appears. In a TabView, views in unselected tabs may be constructed without appearing, and in a NavigationStack a destination is not created until it is pushed.

That is usually correct and occasionally surprising — “why did my data not load” for a tab the user has not opened is the expected behaviour, not a bug.

The related trap: .task on a view inside a ScrollView with LazyVStack fires as rows are realised, which is how you accidentally start two hundred network requests by scrolling quickly. If each row loads something, that load belongs in a model with a cache, not in a per-row .task.

What replaced what

Old New
onAppear + Task { } .task { }
onAppear + Task + onDisappear + cancel() .task { }
onChange(of:) + cancel + restart .task(id:) { }
onReceive(publisher) .task { for await value in stream { … } }

That last row is the one worth knowing about. An AsyncSequence consumed in a .task gives you the same thing onReceive did, with automatic cancellation and no AnyCancellable:

.task {
    for await location in locationManager.updates {
        self.location = location
    }
}

When the view goes, the loop is cancelled and the iteration ends. That is the whole lifecycle, in three lines, with nothing to store and nothing to remember to tear down.