Advanced Concurrency

@MainActor on ViewModels

January 19, 2026
5 min read
Featured image for blog post: @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."

Originally published on deadpixel.llc

© 2026 Christopher Moore / Dead Pixel Studio

Let's work together

Professional discovery, design, and complete technical coverage for your ideas

Get in touch