Using `async`/`await` in SwiftUI: `.task`, `@MainActor`, and Thread-Safe View Updates


You declare @State var films: [PixarFilm] = [], wire up an onAppear block, launch a Task inside it to fetch data from your API, and then — the purple “Publishing changes from background threads is not allowed” warning appears in the console. Or worse: a silent data race that only manifests in production under load. Threading in SwiftUI has rules, and once you know them, the right patterns feel obvious and mechanical.

This guide covers the modern, Swift 6-compatible way to load data in SwiftUI: the .task modifier, @MainActor isolation on view models, and the @Observable macro. We won’t cover UIKit threading or Combine — those have their own dedicated posts.

Contents

The Problem

The classic mistake looks like this. You have a service that fetches Pixar films from a remote API, and you want to load them when a view appears:

struct FilmListView: View {
    @State private var films: [PixarFilm] = []
    private let service = FilmService()

    var body: some View {
        List(films) { film in
            Text(film.title)
        }
        .onAppear {
            Task {
                // ⚠️ Swift 5.x: no compile-time guarantee this runs on MainActor
                // ⚠️ Swift 6: data race — Task inherits caller's isolation,
                //    but `films` requires MainActor isolation
                let fetched = try? await service.fetchAll()
                self.films = fetched ?? []
            }
        }
    }
}

In Swift 5.x with strict concurrency checking disabled, this compiles and often works — but you’re relying on an implicit assumption that Task { } inherits the @MainActor isolation of its surrounding context. That assumption is fragile and became an error in Swift 6.

There are two distinct issues here:

  1. Lifecycle ownership. You’ve created a Task whose lifetime is independent of the view. If the view disappears before the task finishes — because the user navigated away — the task continues running, consumes resources, and then tries to mutate state on a view that’s no longer in the hierarchy.

  2. Actor isolation. In Swift 6’s strict concurrency model, @State properties require writes to happen on the @MainActor. Without explicit isolation, the compiler cannot verify that the assignment self.films = fetched ?? [] is safe.

Both problems have clean solutions.

The .task Modifier

.task(_:) is a SwiftUI view modifier that attaches an asynchronous task to a view’s lifetime. It was introduced in iOS 15 and is now the canonical way to perform async work in SwiftUI.

struct FilmListView: View {
    @State private var films: [PixarFilm] = []
    @State private var error: Error?
    private let service = FilmService()

    var body: some View {
        List(films) { film in
            Text(film.title)
        }
        .task {
            do {
                films = try await service.fetchAll()
            } catch {
                self.error = error
            }
        }
    }
}

This solves both problems simultaneously. The .task modifier:

  • Automatically cancels the task when the view disappears. The Task is tied to the view’s lifetime — no leaks, no stale writes.
  • Runs on the @MainActor by default in a SwiftUI context, because SwiftUI view bodies are @MainActor-isolated. The assignments to films and error are safe without any additional annotation.

Note: .task requires iOS 15 / macOS 12. If you need to support iOS 14, you’ll need the onAppear + Task pattern with explicit @MainActor — but this is increasingly rare in 2026.

Reacting to Value Changes with .task(id:)

.task(id:priority:_:) is the async equivalent of .onChange. It re-executes and cancels the previous task whenever the id value changes. This is the right tool for search-as-you-type, filtered lists, or any data load that depends on user input:

struct FilmSearchView: View {
    @State private var query: String = ""
    @State private var results: [PixarFilm] = []
    private let service = FilmService()

    var body: some View {
        VStack {
            TextField("Search Pixar films...", text: $query)
                .padding()
            List(results) { film in
                Text(film.title)
            }
        }
        .task(id: query) {
            // Cancels and restarts every time `query` changes.
            // The previous fetch is cancelled before this one begins.
            guard !query.isEmpty else {
                results = []
                return
            }
            do {
                results = try await service.search(query: query)
            } catch is CancellationError {
                // Task was cancelled because query changed — this is expected.
                // Do NOT clear results here; the next task will populate them.
            } catch {
                results = []
            }
        }
    }
}

Tip: Always handle CancellationError separately in .task(id:) bodies. Cancellation is a normal part of the lifecycle, not a failure. Treating it as an error often leads to flickering UI where results are cleared mid-keystroke.

Why @MainActor Belongs on Your View Model

Once your view logic grows beyond a couple of state variables, you’ll extract it into a view model. The question is: how do you annotate it?

Before Swift Concurrency, ObservableObject view models ran on whatever thread they were called from, and developers relied on DispatchQueue.main.async to push updates to the main thread. This was error-prone and invisible to the compiler.

The modern approach is to mark the entire view model class with @MainActor:

// ❌ Before: manual thread-hopping, no compile-time safety
class FilmListViewModel: ObservableObject {
    @Published var films: [PixarFilm] = []

    func loadFilms() {
        Task {
            let fetched = try? await FilmService().fetchAll()
            DispatchQueue.main.async {
                self.films = fetched ?? [] // 🤞 Hope we're on main
            }
        }
    }
}

// ✅ After: @MainActor guarantees all access is on the main thread
@MainActor @Observable
final class FilmListViewModel {
    var films: [PixarFilm] = []
    var isLoading = false
    var error: Error?

    private let service: FilmService

    init(service: FilmService = FilmService()) {
        self.service = service
    }

    func loadFilms() async {
        isLoading = true
        defer { isLoading = false }

        do {
            films = try await service.fetchAll()
        } catch {
            self.error = error
        }
    }
}

@MainActor on the class means every stored property and method is isolated to the main actor by default. You get compile-time guarantees that no background thread will ever mutate films or isLoading. The defer { isLoading = false } pattern ensures the loading state is always cleared, even if service.fetchAll() throws.

MainActor.run { } for Wrapping Non-Isolated Code

Sometimes you’re calling into a callback-based API — a legacy networking library, a third-party SDK — that has no awareness of Swift Concurrency. You need to explicitly hop to the main actor to update state. Use MainActor.run:

func loadLegacyFilms() async {
    // `legacyFetchPixarFilms` is a callback-based API that calls back
    // on an arbitrary background queue.
    let films: [PixarFilm] = await withCheckedContinuation { continuation in
        LegacyFilmService.shared.fetchPixarFilms { films in
            continuation.resume(returning: films)
        }
    }

    // We're back in async context but may not be on MainActor.
    // Explicitly jump to the main actor to update state.
    await MainActor.run {
        self.films = films
    }
}

Note: If your entire view model is already @MainActor, you don’t need MainActor.run for property assignments — the isolation is handled at the type level. MainActor.run is for code that lives outside an @MainActor-isolated context.

A Complete FilmListViewModel

Here is a production-ready view model that handles loading, data, and error states using the @Observable macro and @MainActor isolation:

import SwiftUI
import Observation

@MainActor @Observable
final class FilmListViewModel {
    // MARK: - State

    private(set) var films: [PixarFilm] = []
    private(set) var isLoading = false
    private(set) var error: Error?

    var hasError: Bool { error != nil }
    var isEmpty: Bool { !isLoading && films.isEmpty && error == nil }

    // MARK: - Dependencies

    private let service: FilmServiceProtocol

    init(service: FilmServiceProtocol = FilmService()) {
        self.service = service
    }

    // MARK: - Actions

    func loadFilms() async {
        guard !isLoading else { return } // Prevent duplicate requests
        isLoading = true
        error = nil
        defer { isLoading = false }

        do {
            films = try await service.fetchAll()
        } catch is CancellationError {
            // View disappeared while loading — no-op.
            // Don't overwrite existing data or set an error state.
        } catch {
            self.error = error
        }
    }

    func reload() async {
        films = []
        await loadFilms()
    }
}

// MARK: - View

struct FilmListView: View {
    @State private var viewModel = FilmListViewModel()

    var body: some View {
        Group {
            if viewModel.isLoading {
                ProgressView("Loading Pixar films...")
            } else if viewModel.hasError {
                ContentUnavailableView(
                    "Something went wrong",
                    systemImage: "exclamationmark.triangle",
                    description: Text(viewModel.error?.localizedDescription ?? "")
                )
            } else if viewModel.isEmpty {
                ContentUnavailableView(
                    "No films found",
                    systemImage: "film",
                    description: Text("Pull to refresh or check your connection.")
                )
            } else {
                List(viewModel.films) { film in
                    FilmRowView(film: film)
                }
            }
        }
        .navigationTitle("Pixar Films")
        .task {
            await viewModel.loadFilms()
        }
        .refreshable {
            await viewModel.reload()
        }
    }
}

Note that @State private var viewModel = FilmListViewModel() works directly with @Observable — no @StateObject needed. The @Observable macro synthesizes the observation tracking that @StateObject + ObservableObject previously provided.

Apple Docs: @Observable — Observation framework

Advanced Usage

Cancellation Behavior of .task

When a view with a .task modifier is removed from the hierarchy — by a NavigationStack pop, a sheet dismissal, or a condition becoming false — SwiftUI automatically cancels the associated task. This is cooperative cancellation: the task won’t stop instantly. It will stop at the next await point where it checks for cancellation.

This means your async functions need to be cancellation-aware. For URLSession, this is handled automatically — URLSession.data(from:) throws CancellationError when cancelled. For your own loops, check Task.isCancelled:

func processFilmLibrary(films: [PixarFilm]) async throws {
    for film in films {
        // Check before each unit of work in a long-running loop
        try Task.checkCancellation()
        await renderPoster(for: film)
    }
}

Warning: If you don’t check for cancellation, a .task body that performs a tight loop (e.g., iterating over a large array) will continue running until it finishes, even after the view disappears. The cancellation token is set, but Swift can’t force-stop your code.

Task.yield() in Long Computations

For CPU-intensive work that doesn’t naturally await anything — image processing, data transformations — call Task.yield() periodically to give other tasks a chance to run and to check for cancellation:

func generateFilmThumbnails(for films: [PixarFilm]) async throws -> [UIImage] {
    var thumbnails: [UIImage] = []
    for film in films {
        try Task.checkCancellation()
        let thumbnail = renderThumbnail(for: film) // CPU-bound
        thumbnails.append(thumbnail)
        await Task.yield() // Cooperate with the scheduler
    }
    return thumbnails
}

Avoiding Retain Cycles When Capturing self

When you create a Task manually (outside of .task), capturing self strongly creates a retain cycle if the task lives as long as the object. The .task modifier doesn’t have this problem because SwiftUI manages the task’s lifetime. For explicit Task { } calls stored on an object:

// ❌ Retain cycle: the stored Task captures self strongly
final class FilmLibraryViewModel {
    var loadTask: Task<Void, Never>?

    func beginLoading() {
        loadTask = Task {
            await self.loadFilms() // Strong capture
        }
    }
}

// ✅ Weak capture breaks the cycle
final class FilmLibraryViewModel {
    var loadTask: Task<Void, Never>?

    func beginLoading() {
        loadTask = Task { [weak self] in
            await self?.loadFilms()
        }
    }
}

Note: If your view model is @MainActor-isolated and uses @Observable, you typically don’t store Task references manually — you let .task manage them. Stored Task properties are mostly needed for explicit cancellation patterns (e.g., a “Cancel Upload” button).

@Observable + @MainActor: The Modern Replacement for ObservableObject

The @Observable macro (iOS 17+) combined with @MainActor is the modern replacement for the ObservableObject + @Published pattern. Key differences:

  • No @Published property wrappers needed — all stored properties are automatically observable.
  • The view only re-renders when properties it actually accesses change, not when any published property changes.
  • Works with @State in the view (not @StateObject/@ObservedObject).
// iOS 17+
@available(iOS 17.0, *)
@MainActor @Observable
final class PixarLibraryViewModel {
    var films: [PixarFilm] = []       // Automatically observed
    var selectedGenre: String? = nil  // Only views reading this re-render
}

For iOS 16 and below, use ObservableObject + @Published with explicit @MainActor on the class.

Performance Considerations

Choosing .task over onAppear + Task { } is not just a correctness decision — it’s a performance one.

.task is re-entrant-safe. SwiftUI may call onAppear more than once for the same view instance during a layout pass or after the app resumes from background. With onAppear + Task { }, each call creates a new task. With .task, SwiftUI guarantees the task is only running once — if it’s already running, a new .task call will cancel the previous one before starting again.

@Observable is more efficient than @Published. Because @Observable uses fine-grained dependency tracking, a view that only reads viewModel.films will not re-render when viewModel.isLoading changes. With ObservableObject, any @Published change triggers a re-render in all observing views.

Avoid MainActor.run { } for bulk updates. Each MainActor.run call is a context switch. If you’re updating many properties, do it in a single MainActor.run block or within a single @MainActor-isolated function — not in a loop.

// ❌ Many context switches
for film in fetchedFilms {
    await MainActor.run { self.films.append(film) }
}

// ✅ One context switch
await MainActor.run {
    self.films = fetchedFilms
}

When to Use (and When Not To)

ScenarioRecommendation
Loading data when a view appearsUse .task — lifecycle-bound, @MainActor by default
Reloading when a filter or search changesUse .task(id:) — cancels previous fetch automatically
Updating state from a legacy callback APIUse MainActor.run { } to hop to the main actor
A view model observed by multiple viewsUse @MainActor @Observable final class
Supporting iOS 16Use @MainActor class + ObservableObject + @Published
CPU-intensive workUse Task.detached(priority: .background) or an actor
Explicit task cancellation (e.g., cancel button)Store the Task reference and call .cancel() directly
Long-running loops with no natural awaitCall Task.yield() periodically to stay cancellation-aware

Summary

  • .task is the correct way to run async work in SwiftUI — it ties the task lifetime to the view lifetime and provides automatic cancellation when the view disappears.
  • Marking your view model @MainActor gives the compiler proof that all state mutations happen on the main thread, eliminating DispatchQueue.main.async.
  • @Observable (iOS 17+) with @MainActor is the modern replacement for ObservableObject — it’s more efficient and requires less boilerplate.
  • .task(id:) is the async replacement for .onChange — use it to re-fetch data whenever a dependency changes.
  • Always handle CancellationError separately — it’s a normal lifecycle event, not a failure.

Understanding how @MainActor works at the type level is the foundation for understanding actors more broadly. Check out Actors in Swift: Eliminating Data Races at Compile Time to learn how to build your own actor-isolated types for non-UI state.