SwiftUI Async/Await Best Practices
Swift Concurrency has fundamentally changed how we write asynchronous code in iOS. Gone are the days of callback pyramids and completion handler chains. In this post, I will share practical patterns I use daily in my SwiftUI projects.
The @MainActor Pattern for ViewModels
UI updates must happen on the main thread. Annotating your ViewModel with @MainActor guarantees this:
1@MainActor
2final class ProjectListViewModel: ObservableObject {
3 @Published var projects: [Project] = []
4 @Published var isLoading = false
5 @Published var error: AppError?
6
7 private let repository: ProjectRepository
8
9 init(repository: ProjectRepository) {
10 self.repository = repository
11 }
12
13 func loadProjects() async {
14 isLoading = true
15 defer { isLoading = false }
16
17 do {
18 projects = try await repository.fetchAll()
19 } catch {
20 self.error = AppError(error)
21 }
22 }
23}Lifecycle-Aware Async Work with .task
The .task modifier automatically cancels when the view disappears:
1struct ProjectListView: View {
2 @StateObject private var viewModel: ProjectListViewModel
3
4 var body: some View {
5 List(viewModel.projects) { project in
6 ProjectRow(project: project)
7 }
8 .task {
9 await viewModel.loadProjects()
10 }
11 .refreshable {
12 await viewModel.loadProjects()
13 }
14 }
15}Parallel Execution with async let
When you need to fetch independent data simultaneously:
1func loadDashboard() async throws -> Dashboard {
2 async let featuredProjects = repository.fetchFeatured()
3 async let recentPosts = blogRepository.fetchRecent(limit: 5)
4 async let analytics = analyticsService.getSummary()
5
6 return try await Dashboard(
7 projects: featuredProjects,
8 posts: recentPosts,
9 analytics: analytics
10 )
11}This runs all three requests concurrently instead of sequentially, dramatically improving load times.
Structured Concurrency with TaskGroup
For dynamic collections of async work:
1func fetchProjectDetails(ids: [UUID]) async throws -> [ProjectDetail] {
2 try await withThrowingTaskGroup(of: ProjectDetail.self) { group in
3 for id in ids {
4 group.addTask {
5 try await self.repository.fetchDetail(id: id)
6 }
7 }
8
9 var results: [ProjectDetail] = []
10 for try await detail in group {
11 results.append(detail)
12 }
13 return results
14 }
15}Cancellation Handling
Always check for cancellation in long-running operations:
1func processImages(_ images: [UIImage]) async throws -> [ProcessedImage] {
2 var processed: [ProcessedImage] = []
3
4 for image in images {
5 try Task.checkCancellation()
6
7 let result = await imageProcessor.process(image)
8 processed.append(result)
9 }
10
11 return processed
12}Error Handling Pattern
I recommend a centralized error type:
1enum AppError: LocalizedError {
2 case network(URLError)
3 case api(statusCode: Int, message: String)
4 case cancelled
5 case unknown(Error)
6
7 init(_ error: Error) {
8 if error is CancellationError {
9 self = .cancelled
10 } else if let urlError = error as? URLError {
11 self = .network(urlError)
12 } else {
13 self = .unknown(error)
14 }
15 }
16}Key Takeaways
- Always use
@MainActoron ViewModels - Prefer
.taskoveronAppearfor async work - Use
async letfor parallel independent operations - Check cancellation in loops and long operations
- Handle errors with a structured error type
Swift Concurrency makes async code readable, safe, and efficient. Start adopting these patterns in your SwiftUI projects today!