Chapter 11 — @MainActor and global actors

@MainActor is the actor that owns the main thread

@MainActor
final class ProfileViewModel {        // the WHOLE type is main-actor isolated
    var name = ""                     // all stored properties: main-actor
    func refresh() {  }              // all methods: main-actor
}

final class Mixed {
    @MainActor var label: String = "" // just this property is main-actor
    @MainActor func updateUI() {  }  // just this method is main-actor
    func compute() -> Int {  }       // this one is not
}

// A closure can be main-actor isolated:
let onDone: @MainActor () -> Void = { updateLabel() }
flowchart LR BG["background task"] -->|"await vm.refresh()"| HOP["hop to MAIN thread
(the @MainActor executor)"] HOP --> RUN["run refresh() on main"] RUN --> BACK["hop back"]

Where @MainActor shows up in SwiftUI

  • Views are @MainActor. The View protocol's body is main-actor isolated, so everything you
  • @Observable view models are usually @MainActor. Since a view model feeds the UI, marking it
  • The App and Scene types are @MainActor. App startup runs on the main actor.

Getting onto the main actor from elsewhere

await viewModel.updateProgress(0.5)   // viewModel is @MainActor; this hops to main
await MainActor.run {
    progressLabel.text = "Halfway"    // runs on main
}
// A delegate callback documented to run on the main thread, but not typed @MainActor:
func legacyDelegateDidFinish() {
    MainActor.assumeIsolated {
        self.label.text = "Done"      // no await; we assert we're already on main
    }
}
flowchart TB Q{"Need to run on the main actor.
Where am I?"} Q -->|"Have a @MainActor method"| M1["await that method"] Q -->|"One-off closure, currently off main"| M2["await MainActor.run { }"] Q -->|"Already on main, compiler unaware"| M3["MainActor.assumeIsolated { }"]

Building your own global actor

// Support/DatabaseActor.swift
@globalActor
actor DatabaseActor {
    static let shared = DatabaseActor()   // the required singleton instance
}
@DatabaseActor
final class QueryEngine {
    func run(_ sql: String) -> [Row] {  }   // always on the DatabaseActor
}

@DatabaseActor
func migrate() {  }                          // also on the DatabaseActor — serialized with QueryEngine

When to build one: when a subsystem (not a single object) needs serialized access — a database layer, an analytics pipeline, a hardware resource. When you just need to protect one object's state, a plain actor (Chapter 9) is simpler and more focused. Don't reach for a global actor where a plain actor suffices; global isolation is a bigger hammer.

nonisolated inside a @MainActor type

@MainActor
final class ProfileViewModel {
    let userID: UUID
    var displayName = ""

    init(userID: UUID) { self.userID = userID }

    // Pure, touches only the immutable userID — no reason to require the main thread.
    nonisolated var analyticsKey: String { "profile_\(userID)" }
}

What we built in this chapter

  • Established @MainActor as the global actor bound to the main thread — the mechanism for making
  • Saw why SwiftUI needs so few explicit annotations: **Views, @Observable view models, and App
  • Learned the three ways to get onto the main actor from elsewhere — await a @MainActor method,
  • Built a custom global actor (@globalActor + static let shared) to give a subsystem a single,
  • Used nonisolated inside a @MainActor type to keep pure helpers hop-free.

Mental model to take away

  • @MainActor is just an actor whose executor is the main thread. All the Chapter 9 rules apply:
  • SwiftUI puts you on the main actor by default (views, view models, App), so the work is at the
  • assumeIsolated is an assertion, not an escape hatch — it traps if you're wrong. Use it only for
  • A global actor (@globalActor) gives a whole subsystem one shared serialized domain; a plain