Most views need data that arrives asynchronously, such as a network response, a database query, or a file read. A common first approach is to start that work in onAppear(perform:). That works for synchronous setup, but it leaves the cleanup to you: you need to store the work, cancel it when the view goes away, and restart it when the input changes.
The task(priority:_:) and task(id:priority:_:) modifiers handle all of this. You write an async closure, and SwiftUI runs it when the view appears, cancels it when the view disappears, and restarts it when a value you specify changes.
This article compares the two approaches, shows how to move from a manually managed Task to .task(id:), and covers the practices that make the modifier reliable in production code.
onAppear(perform:) takes a synchronous closure. To call async code from it, you create an unstructured Task. SwiftUI doesn't know that task exists, so it can't manage it for you.
task(priority:_:) takes an async closure. SwiftUI creates the task, owns it, and connects it to the view's lifecycle.
| Behavior | .onAppear + Task { } |
.task |
|---|---|---|
Accepts async code directly |
No; you wrap it in a Task |
Yes |
| Cancels when the view disappears | Only if you cancel it yourself | Automatically |
| Restarts when an input changes | Only if you add onChange logic |
Automatically with id: |
| Requires stored task state | Yes | No |
| Risk of stale or duplicate work | High if you miss a step | Low |
Note:
.taskis available in iOS 15, iPadOS 15, macOS 12, tvOS 15, watchOS 8, and later.
Consider a view that shows current conditions for a hiking trail. When the person selects a different trail, the view loads a new report. Using onAppear, the view has to keep track of its own work:
struct TrailConditionsView: View {
let trailID: Trail.ID
@State private var report: ConditionsReport?
@State private var loadingTask: Task<Void, Never>?
var body: some View {
ConditionsContent(report: report)
.onAppear {
startLoading()
}
.onChange(of: trailID) {
startLoading()
}
.onDisappear {
loadingTask?.cancel()
loadingTask = nil
}
}
private func startLoading() {
loadingTask?.cancel()
loadingTask = Task {
await loadReport()
}
}
private func loadReport() async {
do {
report = try await ConditionsService.shared.report(for: trailID)
} catch {
// Handle the error.
}
}
}This code is correct, but it depends on several pieces working together:
Task property that exists only for bookkeeping.onAppear, onChange, and onDisappear) that you must keep in sync.If any of these pieces is missing, the view can keep downloading after it leaves the screen, or a slow response for an earlier trail can arrive after a faster one and overwrite the correct report.
.task(id:)Here's the same view using task(id:priority:_:):
struct TrailConditionsView: View {
let trailID: Trail.ID
@State private var report: ConditionsReport?
var body: some View {
ConditionsContent(report: report)
.task(id: trailID) {
await loadReport()
}
}
private func loadReport() async {
do {
report = try await ConditionsService.shared.report(for: trailID)
} catch {
// Handle the error.
}
}
}The stored task, the helper method, and two of the three modifiers are gone. SwiftUI now handles the lifecycle:
trailID changes, SwiftUI cancels the running task and starts a new one with the new value.Because SwiftUI cancels the earlier task before it starts the next one, you don't need extra code to keep a stale report from replacing a current one, as long as your work responds to cancellation.
Tip: Use
task(priority:_:)without anidwhen the work depends only on the view appearing. Usetask(id:priority:_:)when the work depends on a value that can change while the view is on screen.
SwiftUI cancels a task by marking it as cancelled. Swift concurrency uses cooperative cancellation, so your code is responsible for noticing that signal and stopping.
Many system APIs already do this. URLSession async methods and Task.sleep(for:) throw an error when their task is cancelled. For long-running work that you write yourself, check for cancellation at natural stopping points:
func processSegments(_ segments: [TrailSegment]) async throws -> [ElevationSample] {
var samples: [ElevationSample] = []
for segment in segments {
try Task.checkCancellation()
samples.append(contentsOf: await sampleElevation(for: segment))
}
return samples
}Also treat cancellation as an expected outcome rather than a failure. Don't show an error alert because the person navigated away:
private func loadReport() async {
do {
report = try await ConditionsService.shared.report(for: trailID)
} catch is CancellationError {
// The view disappeared or the trail changed. No action needed.
} catch let error as URLError where error.code == .cancelled {
// URLSession reports cancellation as a URL error.
} catch {
errorMessage = error.localizedDescription
}
}Important: Cancellation doesn't interrupt code that's already running. If your task performs a long synchronous computation, it runs to completion unless you check
Task.isCancelledor callTask.checkCancellation()along the way.
.task(id:)Because .task(id:) cancels the previous run every time its value changes, it also works well for debouncing. In this search view, each keystroke cancels the pending search, so the query runs only after the person pauses typing:
struct TrailSearchView: View {
@State private var query = ""
@State private var results: [Trail] = []
var body: some View {
List(results) { trail in
Text(trail.name)
}
.searchable(text: $query)
.task(id: query) {
do {
try await Task.sleep(for: .milliseconds(300))
results = try await TrailIndex.shared.search(matching: query)
} catch is CancellationError {
// A newer query replaced this one.
} catch {
results = []
}
}
}
}If the person types another character within 300 milliseconds, SwiftUI cancels the sleeping task, Task.sleep(for:) throws, and the outdated search never runs. You get debouncing without a timer, a Combine pipeline, or any stored state beyond the query itself.
The id parameter accepts any Equatable value. When the work depends on more than one input, group those inputs in a small Equatable type:
struct FeedRequest: Equatable {
var region: Region
var difficulty: Difficulty
}
struct TrailFeedView: View {
@State private var region: Region = .pacificNorthwest
@State private var difficulty: Difficulty = .moderate
@State private var trails: [Trail] = []
var body: some View {
TrailList(trails: trails)
.task(id: FeedRequest(region: region, difficulty: difficulty)) {
trails = (try? await TrailService.shared.trails(
in: region,
difficulty: difficulty
)) ?? []
}
}
}A change to either property produces a different FeedRequest, which restarts the task. Changes to unrelated state don't affect it.
.task is the right default for async work that belongs to a view, but it isn't the right fit in every case.
Use .task when:
Keep using .onAppear when:
Use a task owned by a model or service when:
.task cancels when the view goes away, it can interrupt work that needs to run to completion.Note: A view can disappear and reappear more often than you expect. For example, the root of a navigation stack disappears when you push a new view and reappears when you return, which runs its task again. Design your task to be safe to repeat, or cache results in your model so a repeat run is inexpensive.
Moving async work from .onAppear to .task is a small change in code but a meaningful change in responsibility. Instead of creating a task, storing it, cancelling it, and remembering to restart it, you describe the work that belongs to the view and the value it depends on. SwiftUI handles the timing.
That shift pays off as views grow. Every extra lifecycle modifier and stored Task is another place for a stale response or a forgotten cancellation to hide. .task(id:) replaces them with a single declaration that reads the way the behavior works: run this work for this value, for as long as this view is on screen.
In return, the modifier asks one thing of you: write async code that respects cancellation. When your functions check for cancellation and treat it as an expected outcome, .task becomes a dependable default for loading data, debouncing input, and reacting to changes in SwiftUI.
The next time you reach for .onAppear { Task { … } }, try .task first. In most cases, you'll write less code that does more.
Thank you for reading. If you have any questions feel free to follow me on X and send me a DM. If this article helped you, Buy me a coffee.