Swift Concurrency Best Practices (Swift 6+)
1. Structured Concurrency
- Prefer
async letfor parallel tasks when the number of tasks is known. - Use
TaskGroupfor a dynamic number of parallel tasks. - Avoid
Task {...}(unstructured) unless bridging from synchronous code (e.g., UI event handlers) or firing background work that outlives the scope.
2. Actors & Isolation
- Default to
@MainActorfor all UI-related classes (ViewModels, SwiftUI Views). - Use
actorfor shared mutable state that is *not* UI-related (e.g., caching, database managers). - Global Actors: Use
@MainActoror custom global actors to synchronize access across different types.
3. Sendability
- Strict Concurrency Checking: Assume
Strict Concurrencyis ON. - Mark immutable structs/enums as
Sendable(often implicit, but be explicit if public). - For classes, use
finaland@unchecked Sendable*only* if you are manually managing thread safety (locks/queues). Ideally, useactor. - Closures: Ensure closures passed between contexts are
@Sendable. - TCA types: State and Reducer structs should conform to
Sendable.
4. Observable Pattern
- Prefer
@Observable(Observation framework) overObservableObject+@Published. - Pattern:
@Observable public final class ViewModel. - Combine
@Observablewith@MainActorwhen accessing UI state from async contexts.
5. Migration from Combine/Closures
- Replace
DispatchQueue.main.asyncwithawait MainActor.run {...}or isolate the function itself. - Replace
Future/Promisewith directasync throwsfunctions. - Use
AsyncStreamto replace simple CombinePassthroughSubjectscenarios.
Example: Safe ViewModel
@Observable
@MainActor
final class UserViewModel {
var users: [User] = []
private let client: APIClient
init(client: APIClient) {
self.client = client
}
func load() async {
do {
self.users = try await client.fetchUsers()
} catch {
print(error)
}
}
}