@MainActor on ViewModels

The @MainActor attribute ensures code runs on the main thread. For ViewModels, this is essential—but there's a right way to use it.
Follow along with the code: iOS-Practice on GitHub
Why ViewModels Need @MainActor
ViewModels update @Published properties that drive UI. Those updates MUST happen on the main thread:
// Without @MainActor - potential crash
class UserViewModel: ObservableObject {
@Published var user: User?
func load() async {
let user = await api.fetchUser() // Background thread
self.user = user // ⚠️ Main thread violation!
}
}
The Fix: @MainActor Class
Mark the entire class:
@MainActor
class UserViewModel: ObservableObject {
@Published var user: User?
@Published var isLoading = false
func load() async {
isLoading = true
let user = await api.fetchUser()
self.user = user // ✅ Guaranteed main thread
isLoading = false
}
}
Now ALL methods and properties run on the main thread by default.
Calling @MainActor Methods
From a Task, you can call directly:
struct UserView: View {
@StateObject var viewModel = UserViewModel()
var body: some View {
VStack {
// ...
}
.task {
await viewModel.load() // Works - task inherits main actor context
}
}
}
From non-isolated code:
func fetchInBackground() async {
await viewModel.load() // await required for actor hop
}
@MainActor vs DispatchQueue.main
Old way (still works, but verbose):
class OldViewModel: ObservableObject {
@Published var data: [Item] = []
func load() {
Task {
let items = await api.fetchItems()
await MainActor.run {
self.data = items
}
}
}
}
New way (cleaner):
@MainActor
class NewViewModel: ObservableObject {
@Published var data: [Item] = []
func load() async {
data = await api.fetchItems()
}
}
Complete ViewModel Pattern
@MainActor
class ProductListViewModel: ObservableObject {
@Published var products: [Product] = []
@Published var isLoading = false
@Published var error: Error?
private let repository: ProductRepository
init(repository: ProductRepository = ProductRepository()) {
self.repository = repository
}
func loadProducts() async {
isLoading = true
error = nil
do {
products = try await repository.fetchAll()
} catch {
self.error = error
}
isLoading = false
}
func refresh() async {
// Don't show loading indicator for refresh
do {
products = try await repository.fetchAll()
} catch {
self.error = error
}
}
func delete(_ product: Product) async {
do {
try await repository.delete(product)
products.removeAll { $0.id == product.id }
} catch {
self.error = error
}
}
}
When NOT to Use @MainActor
Heavy computation should stay off the main thread:
@MainActor
class ImageProcessor: ObservableObject {
@Published var processedImage: UIImage?
// ❌ This blocks the main thread
func process(_ image: UIImage) {
processedImage = applyFilters(image) // Expensive!
}
// ✅ Better: use nonisolated for heavy work
nonisolated func processAsync(_ image: UIImage) async -> UIImage {
return applyFilters(image) // Runs on background
}
func processAndUpdate(_ image: UIImage) async {
let result = await processAsync(image)
processedImage = result // Back on main for assignment
}
}
Interview Tip
When discussing ViewModels, mention: "I mark my ViewModels with @MainActor to guarantee all @Published updates happen on the main thread. This eliminates an entire class of threading bugs and makes the code cleaner than manual MainActor.run calls."