SwiftUI Concurrency Guide
Concurrency patterns and best practices specific to SwiftUI applications.
Contents
- MainActor Default in SwiftUI
- Where SwiftUI Runs Code Off the Main Thread
- Sendable Closures and Data-Race Safety
- Structuring Async Work
- The .task Modifier
@Observable View Models- Async Observation with Observations (SE-0475)
- Performance-Driven Concurrency
- Common SwiftUI Concurrency Mistakes
MainActor Default in SwiftUI
Viewis@MainActorisolated by default;bodyand all members inherit this isolation.- Swift 6.2 can infer
@MainActorfor all types in a module via default actor isolation (SE-0466). - This default aligns with UIKit/AppKit
@MainActorAPIs and simplifies UI code.
Where SwiftUI Runs Code Off the Main Thread
SwiftUI may evaluate some view logic on background threads for performance:
Shapepath generationLayoutmethods (sizeThatFits,placeSubviews)visualEffectclosuresonGeometryChangeclosures
These APIs often require Sendable closures to reflect their off-main-thread
runtime semantics.
Sendable Closures and Data-Race Safety
Accessing @MainActor state from a Sendable closure is unsafe and flagged by
the compiler.
Fix: Capture value copies in the closure capture list.
// WRONG: Captures @MainActor state directly
.visualEffect { content, proxy in
content.offset(y: self.offset) // Error: @MainActor state in Sendable closure
}
// CORRECT: Capture a copy
let currentOffset = offset
// ... use in closure:
.visualEffect { [currentOffset] content, proxy in
content.offset(y: currentOffset)
}Avoid sending self into a Sendable closure just to read a single property.
Structuring Async Work
SwiftUI action callbacks are synchronous so UI updates (like loading states) can be immediate.
struct ContentView: View {
@State private var isLoading = false
@State private var result: String?
var body: some View {
Button("Load") {
isLoading = true // Immediate UI update
Task {
result = await fetchData()
isLoading = false
}
}
}
}Pattern: Use state as the boundary. Async work updates model/state; UI reacts synchronously.
The .task Modifier
Prefer .task over manual Task creation in views:
.task {
await loadInitialData()
}Advantages:
- Automatically cancels on view disappear.
- Inherits the view's actor isolation (
@MainActor). - No need to store
Taskreferences for cancellation.
Use .task(id:) to restart work when a value changes:
.task(id: selectedItem) {
details = await fetchDetails(for: selectedItem)
}@Observable View Models
- Annotate view models with both
@Observableand@MainActor. - Use
@Stateto own an@Observableinstance (replaces@StateObject). - Avoid
@ObservedObject/@StateObject/ObservableObjectin new code.
@Observable @MainActor
final class ViewModel {
var items: [Item] = []
var isLoading = false
func load() async {
isLoading = true
items = await fetchItems()
isLoading = false
}
}
struct ItemListView: View {
@State private var viewModel = ViewModel()
var body: some View {
List(viewModel.items) { item in
Text(item.name)
}
.task { await viewModel.load() }
}
}Async Observation with Observations (SE-0475)
Use Observations { } for transactional async observation:
.task {
for await _ in Observations { viewModel.searchText } {
await viewModel.performSearch()
}
}Performance-Driven Concurrency
- Offload expensive work from the main actor to avoid hitches.
- Keep time-sensitive UI logic (animations, gesture responses) synchronous.
- Separate UI code from long-running async work.
@Observable @MainActor
final class ImageProcessor {
var processedImage: UIImage?
func process(data: Data) async {
// Offload heavy work
let result = await Self.runProcessing(data: data)
processedImage = result
}
@concurrent
nonisolated static func runProcessing(data: Data) async -> UIImage {
// Runs on background thread pool
// ...
}
}Common SwiftUI Concurrency Mistakes
- Creating
Taskinbody. Use.taskmodifier instead. - Not cancelling tasks.
.taskdoes this automatically; manualTaskreferences must be cancelled inonDisappear. - Blocking MainActor in view updates. Move heavy computation to
@concurrentfunctions. - Using
Task.detachedin views. Loses actor context. UseTask { }or.taskmodifier. - Updating state from background. Always update
@State/@Observableproperties on@MainActor.