Every background callback that touched UI needed a DispatchQueue.main.async hop, placed at exactly the right point. Miss one and you get a UIKit assertion, or worse, a silently corrupted layout pass.
#The old way
final class FeedViewModel {
var posts: [String] = []
var onUpdate: (() -> Void)?
func refresh() {
NetworkClient.fetchPosts { [weak self] result in
guard let self = self else { return }
DispatchQueue.main.async {
self.posts = result
self.onUpdate?()
}
}
}
}
#The new way
import Observation
@MainActor
@Observable
final class FeedViewModel {
var posts: [String] = []
func refresh() async {
let result = await NetworkClient.fetchPosts()
posts = result
}
}
enum NetworkClient {
static func fetchPosts() async -> [String] {
["First post", "Second post"]
}
}
#Why it matters
- Every property and method on
FeedViewModelis guaranteed to run on the main actor — no manual dispatch needed anywhere in the class - SwiftUI views can read and mutate the view model directly without wrapping every call site in a queue hop
nonisolatedmethods and backgroundasyncfunctions can still be called from inside a@MainActortype viaawait, so networking code doesn't need to live on the main actor too- Isolation is checked at compile time, not discovered at runtime when an assertion fires
#Gotcha
@MainActor promises which executor runs the work. It promises nothing about when.
Call a @MainActor async function from non-main-actor code and you still suspend at the await, and the call gets scheduled onto the main actor's queue. The calling thread does not block until the main actor is free. And if other main-actor work is already queued ahead of yours, the code after the await is not guaranteed to run before the next screen render.