Skip to Content
Course content

104: Global Actors and @MainActor

Click on the "Edit" button in the top corner of the screen to edit your slide content.

You've probably seen this before: your app is running fine, but suddenly Xcode throws a bright purple warning in the console: "Publishing changes from background threads is not allowed." Or worse, your UI just hangs for a split second and then crashes.

This usually happens when you're using Swift Concurrency. You've written a nice, clean async function to fetch some data, and you're thinking, "Great, this is off the main thread, my UI stays responsive." But then you forget one crucial detail: the code that updates the UI must happen on the main thread. Let's look at a mistake I see all the time in ViewModels.

class UserProfileViewModel: ObservableObject {
    @Published var username = "Loading..."
    
    func updateUsername() async {
        // Simulate a network call
        let newName = await NetworkService.fetchUsername()
        
        // BUG: This happens on whatever thread the 
        // network call finished on, NOT necessarily the main thread.
        self.username = newName 
    }
}

The Thread Jump Trap

At first glance, this looks perfect. It's async, it awaits the network call, and then it updates the property. The problem is that updateUsername isn't tied to any specific actor. When NetworkService.fetchUsername() returns, the execution resumes on an arbitrary background thread provided by the cooperative pool.

When you set self.username = newName, you're triggering a UI update via ObservableObject. SwiftUI expects those updates to happen on the Main Thread. If you're lucky, you get a warning. If you're unlucky, you get a race condition that makes your app behave unpredictably.

Guaranteeing UI Safety with @MainActor

This is where Global Actors come in. A Global Actor is essentially a singleton actor that allows us to mark specific functions, properties, or entire classes as needing to run on a specific executor. The most important one is @MainActor, which is a global actor that represents the main thread.

The most robust way to fix the ViewModel bug is to mark the entire class as @MainActor. This tells Swift: "Everything in this class should happen on the main thread by default."

@MainActor
class UserProfileViewModel: ObservableObject {
    @Published var username = "Loading..."
    
    func updateUsername() async {
        // The network call still happens asynchronously (off the main thread)
        let newName = await NetworkService.fetchUsername()
        
        // Because the class is @MainActor, Swift ensures we "hop" back 
        // to the main thread before executing this line.
        self.username = newName 
    }
}

I prefer marking the whole ViewModel as @MainActor because it eliminates the need to remember to mark every single property or method. It creates a "safe zone" for your UI logic.

Granular Control and MainActor.run

Sometimes you don't want an entire class on the main actor—maybe it does heavy data processing that would freeze the UI. In those cases, you can mark just the specific function, or use MainActor.run for a one-off update.

If you have a function that does a lot of heavy lifting and only one line of UI update, do this:

func processLargeDataset() async {
    // Heavy computation happens on a background thread
    let result = await performComplexCalculation()
    
    // Explicitly hop to the main actor just for the UI update
    await MainActor.run {
        self.resultLabel = result
    }
}

Think of @MainActor as a contract. You're telling the compiler, "I guarantee this code runs on the main thread," and the compiler then handles the complex logic of switching threads for you. It's far safer than the old DispatchQueue.main.async because the compiler can actually check your work at build time.




📋 Practical Task

Exercise: Fixing the Live Stock Price Ticker

You are building a stock ticker app. The StockTickerViewModel uses a timer to simulate live price updates. However, the current implementation is causing thread-safety warnings because the prices are being updated on a background timer thread.

Your Task: Modify the following code to ensure all UI updates happen on the main thread using the @MainActor attribute. The most efficient way is to mark the entire ViewModel class.

import Foundation

class StockTickerViewModel: ObservableObject {
    @Published var currentPrice: String = "$0.00"
    
    func startPriceUpdates() {
        // This simulates a timer firing on a background thread
        Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
            Task {
                await self.fetchLatestPrice()
            }
        }
    }
    
    func fetchLatestPrice() async {
        // Simulate network latency
        try? await Task.sleep(nanoseconds: 500_000_000)
        let randomPrice = Double.random(in: 100...150)
        
        // FIX THIS: This update is currently happening on a background thread
        self.currentPrice = String(format: "$%.2f", randomPrice)
    }
}

Requirements:

  • Apply @MainActor to the StockTickerViewModel class.
  • Ensure the code still compiles and the fetchLatestPrice function remains async.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.