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, never onAppear { Task { … } }. .task gives 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 the await) and driven a @MainActor @Observable model
  • Covered AsyncImage and the explicit Loadable enum pattern for representable async UI states.
  • Catalogued the pitfalls: no Task { } in body, .task restarts on identity change, **don't do

Mental model to take away

  • .task binds 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 @Observable model 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