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
.taskModifier - Why
@MainActorBelongs on Your View Model - A Complete
FilmListViewModel - Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
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:
-
Lifecycle ownership. You’ve created a
Taskwhose 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. -
Actor isolation. In Swift 6’s strict concurrency model,
@Stateproperties require writes to happen on the@MainActor. Without explicit isolation, the compiler cannot verify that the assignmentself.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
Taskis tied to the view’s lifetime — no leaks, no stale writes. - Runs on the
@MainActorby default in a SwiftUI context, because SwiftUI view bodies are@MainActor-isolated. The assignments tofilmsanderrorare safe without any additional annotation.
Note:
.taskrequires iOS 15 / macOS 12. If you need to support iOS 14, you’ll need theonAppear+Taskpattern 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
CancellationErrorseparately 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 needMainActor.runfor property assignments — the isolation is handled at the type level.MainActor.runis 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
.taskbody 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 storeTaskreferences manually — you let.taskmanage them. StoredTaskproperties 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
@Publishedproperty 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
@Statein 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)
| Scenario | Recommendation |
|---|---|
| Loading data when a view appears | Use .task — lifecycle-bound, @MainActor by default |
| Reloading when a filter or search changes | Use .task(id:) — cancels previous fetch automatically |
| Updating state from a legacy callback API | Use MainActor.run { } to hop to the main actor |
| A view model observed by multiple views | Use @MainActor @Observable final class |
| Supporting iOS 16 | Use @MainActor class + ObservableObject + @Published |
| CPU-intensive work | Use 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 await | Call Task.yield() periodically to stay cancellation-aware |
Summary
.taskis 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
@MainActorgives the compiler proof that all state mutations happen on the main thread, eliminatingDispatchQueue.main.async. @Observable(iOS 17+) with@MainActoris the modern replacement forObservableObject— 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
CancellationErrorseparately — 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.