Chapter 16 — Concurrency in SwiftUI
.task: async work tied to a view's life
// Fetchr, done right: load when the view appears, auto-cancel on disappear.
struct ProfileView: View {
@State private var profile: Profile?
let userID: User.ID
var body: some View {
content
.task {
profile = try? await APIClient().fetch(Profile.self, from: profileURL(userID))
}
}
}
Rule: for "load data when this view shows," use
.task, neveronAppear { Task { … } }..taskgives you free cancellation and the right isolation; the manual version gives you a leak waiting to happen.
.task(id:): restart when something changes
// SearchNow, the SwiftUI way — debounce + cancellation for free.
struct SearchView: View {
@State private var query = ""
@State private var results: [SearchResult] = []
var body: some View {
List(results) { Text($0.title) }
.searchable(text: $query)
.task(id: query) { // re-runs each time `query` changes
guard !query.isEmpty else { results = []; return }
do {
try await Task.sleep(for: .milliseconds(300)) // debounce
results = try await search(query)
} catch {
// CancellationError when the query changes again — ignore
}
}
}
}
.refreshable: pull-to-refresh, async-native
List(articles) { ArticleRow($0) }
.refreshable {
articles = try? await api.fetchLatest() // spinner shows until this completes
}
Feeding an @Observable view model
// LiveTicker, wired properly.
@MainActor @Observable
final class TickerModel {
private(set) var latestPrice: Price?
func observe(symbol: String) async {
for await price in PriceService.shared.priceUpdates(for: symbol) {
latestPrice = price // on the main actor (model is @MainActor) → safe UI update
}
}
}
struct TickerView: View {
@State private var model = TickerModel()
let symbol: String
var body: some View {
PriceLabel(model.latestPrice)
.task { await model.observe(symbol: symbol) } // stream consumed for the view's lifetime
}
}
AsyncImage and other built-ins
AsyncImage(url: avatarURL) { phase in
switch phase {
case .empty: ProgressView()
case .success(let img): img.resizable().scaledToFit()
case .failure: Image(systemName: "person.slash")
@unknown default: EmptyView()
}
}
enum Loadable<Value> { case idle, loading, loaded(Value), failed(Error) }
The pitfalls
var body: some View {
Text(title)
Task { await load() } // ❌ NO — a new task every time body runs
}
flowchart TB
Q{"Where does this async work belong?"}
Q -->|"Load/stream for this view"| T[".task / .task(id:)"]
Q -->|"User tapped something"| B["Task { } in the action closure"]
Q -->|"Heavy CPU"| C["actor / @concurrent, await the result"]
Q -->|"Must outlive the view"| M["a model/service that owns the task"]
What we built in this chapter
- Gave Fetchr, SearchNow, and LiveTicker their proper SwiftUI wiring with
.task(async work - Reduced Chapter 4's hand-written debounce to a single
.task(id: query), which cancels-and-restarts - Used
.refreshable(spinner tied to theawait) and driven a@MainActor @Observablemodel - Covered
AsyncImageand the explicitLoadableenum pattern for representable async UI states. - Catalogued the pitfalls: no
Task { }inbody,.taskrestarts on identity change, **don't do
Mental model to take away
.taskbinds async work to a view's lifetime — starts on appear, cancels on disappear, runs on the.task(id:)restarts on change, making debounced reload and "load when input changes" trivial —- Put real logic in a
@MainActor @Observablemodel driven by.task; keep the body declarative. - The main actor is for updating UI, not grinding on it — offload heavy work to an actor or