How to use MainActor with Observable Macro in SwiftUI | Modern Swift Concurrency #19
The @Observable macro (introduced in iOS 17 / macOS 14) is a modern replacement for ObservableObject + @Published — it eliminates boilerplate and enables more granular SwiftUI observation. But it does not replace @MainActor: you still need explicit actor isolation to guarantee that state mutations happen on the main thread in a Swift 6 concurrent world.
What You'll Learn
- What
@Observableis and how it replacesObservableObject+@Publishedwith less boilerplate - Why
@Observablealone does not provide thread safety and why@MainActoris still required - How to use
@Stateinstead of@StateObjectwhen a model is annotated with@Observable - How
nonisolatedlets you run pure computation or network work off the main actor within a@MainActorclass - How the combination of
@MainActor @Observablecompares to the older@MainActor ObservableObjectpattern
Mental Model
Think of @Observable as a smart whiteboard. In the old system (ObservableObject), you had to mark each specific line on the whiteboard with a flag (@Published) to say "SwiftUI, watch this line." If you forgot a flag, SwiftUI wouldn't know to redraw when that line changed. You could also have unnecessary redraws if a flagged line changed but the view didn't actually use it.
@Observable replaces all those flags with a smart sensor built into the whiteboard itself — it watches exactly which lines each view reads, and only triggers a redraw when one of those specific lines changes. No flags needed, no forgotten flags, no unnecessary redraws.
But @MainActor is a different concern entirely — it is the rule that says "only the designated marker (the main thread) is allowed to write on this whiteboard." @Observable improves how SwiftUI watches the board; @MainActor controls who is allowed to write on it. You need both: the smart sensor for efficient observation, and the access control for thread safety.
Detailed Explanation
Before @Observable (iOS 16 and earlier), the standard pattern for a SwiftUI view model was: class ViewModel: ObservableObject { @Published var name: String = "" }. For each piece of state, @Published was required. The view used @StateObject or @ObservedObject to subscribe. SwiftUI invalidated the entire view whenever any @Published property changed, even those the view did not read.
@Observable (from the Observation framework, iOS 17+) changes this significantly. You annotate a class with @Observable and remove all @Published annotations — the macro synthesizes the observation tracking infrastructure automatically. SwiftUI now tracks property access at runtime: a view that reads only model.names will re-render only when names changes, not when isLoading changes. This fine-grained observation reduces unnecessary view updates.
The property wrapper changes too: views using @Observable models use @State (not @StateObject) to own a model instance, and @Environment or plain stored properties to observe a shared model. @StateObject and @ObservedObject are for ObservableObject types only.
Crucially, @Observable does not imply @MainActor isolation. A class annotated only with @Observable has no actor isolation — its properties can technically be mutated from any thread. In Swift 6 strict mode, mutating an observed property from a background task without proper isolation is a data race and a compiler error. Adding @MainActor to the class declaration is the correct fix: it means every property access and method call is isolated to the main actor.
The combination of @MainActor @Observable is the modern, recommended pattern for iOS 17+ view models. It gives you @Observable's granular tracking and zero-boilerplate observation, with @MainActor's thread-safety guarantee. The nonisolated keyword lets you step off the main actor for pure computations or background work — nonisolated private func fetchNames() runs on the cooperative thread pool rather than the main thread, which is appropriate for any work that does not touch the model's state directly.
Code Structure
The sample (19-how-to-use-mainactor-with-observable-macro-in-swiftui.swift) contains two declarations: MainActorObservableModel (a @MainActor @Observable class with isLoading, names, errorMessage, and a nonisolated fetch function) and MainActorObservableLessonView (a SwiftUI view using @State to own the model, demonstrating all three state conditions: loading, loaded, and error).
Complete Code
19-how-to-use-mainactor-with-observable-macro-in-swiftui.swift
import SwiftUI
import Observation // required for @Observable
// @MainActor: every property mutation and method call is on the main thread.
// @Observable: synthesizes observation tracking — no @Published annotations needed.
// The order matters: @MainActor comes first, then @Observable.
@MainActor
@Observable
final class MainActorObservableModel {
var isLoading = false // no @Published needed — @Observable tracks this automatically
var names: [String] = []
var errorMessage: String?
func load() async {
isLoading = true // SwiftUI views reading isLoading are notified immediately
errorMessage = nil // clear any previous error before starting a new load
do {
names = try await fetchNames() // suspend; resume when fetchNames completes
} catch {
errorMessage = "Could not load names." // user-facing message, not a raw error description
}
isLoading = false // always reset loading state, even on error
}
// nonisolated: this function runs off the main actor on the cooperative thread pool.
// It has no access to the class's isolated properties (isLoading, names, errorMessage).
// Appropriate for CPU work or I/O that doesn't touch UI state directly.
nonisolated private func fetchNames() async throws -> [String] {
try await Task.sleep(nanoseconds: 500_000_000) // 0.5s simulated network delay
return ["Swift", "SwiftUI", "Concurrency"]
}
}
struct MainActorObservableLessonView: View {
// @State (not @StateObject) is correct for @Observable models.
// SwiftUI tracks which properties of `model` this view reads and re-renders precisely.
@State private var model = MainActorObservableModel()
var body: some View {
List {
if model.isLoading {
ProgressView() // shown while isLoading is true; hidden automatically when false
}
ForEach(model.names, id: \.self) { name in
Text(name) // this view re-renders only when `names` changes, not when isLoading changes
}
if let errorMessage = model.errorMessage {
Text(errorMessage)
.foregroundStyle(.red) // displayed only when an error occurred
}
}
// .task runs load() when the view appears; cancelled automatically on disappear.
.task {
await model.load()
}
}
}Code Walkthrough
@MainActor @Observable final class MainActorObservableModel— The two annotations serve different roles.@MainActorestablishes actor isolation — every method and property access is serialized on the main thread.@Observablesynthesizes the observation tracking that SwiftUI uses for fine-grained view invalidation. Neither implies the other; you need both.No
@Publishedannotations —@Observabletracks access to all stored properties automatically. The macro synthesizes_$observationRegistrarand access/mutation wrappers under the hood. SwiftUI's rendering engine reads these wrappers when your view body executes and registers dependencies precisely on the properties that are actually read.isLoading = trueandisLoading = falseframing — SettingisLoadingbeforeawait fetchNames()and after allows the view to show a loading indicator during the async operation. Because the class is@MainActor, both assignments are on the main thread and SwiftUI picks them up in the correct order. IfisLoading = falsewere set from a background thread, it could race with the main-thread UI update.nonisolated private func fetchNames()— Because the class is@MainActor, all methods inherit main-actor isolation by default.nonisolatedopts this one method out.fetchNamesdoes not read or write any of the model's properties — it just returns a value. Running it on the cooperative thread pool frees the main thread during theTask.sleepsimulation. In a real app this would be aURLSessioncall or a heavy JSON decoding step.names = try await fetchNames()— Theawaitsuspendsload()on the main actor. While suspended, the main thread is free to handle UI events. WhenfetchNames()returns (from the cooperative thread pool), Swift resumesload()back on the main actor, and the assignment tonameshappens on the main thread.@State private var model = MainActorObservableModel()—@Stateis correct here because the model is@Observable.@StateObjectis forObservableObjecttypes only. Using@StateObjectwith an@Observablemodel will compile but will not give you fine-grained observation — SwiftUI will fall back to invalidating the entire view on any change.
Common Mistakes
Mistake: Using @Observable without @MainActor and assuming the model is thread-safe.@Observable is an observation mechanism, not an isolation mechanism. A class marked only with @Observable has no actor isolation — its properties can be written from any thread. In Swift 6 strict mode, mutating an @Observable property from a Task that is not main-actor-isolated is a data race and a compiler error. Always pair @Observable with @MainActor for view models, or explicitly handle actor isolation.
Mistake: Annotating the @Observable model as @MainActor but putting slow parsing or decoding work inside a method without nonisolated.
If fetchNames() is a main-actor method doing JSON parsing of a large payload, it blocks the main thread for the duration of the parse. The UI freezes, animations stutter, and the app appears unresponsive. Mark CPU-intensive methods nonisolated so they run on the cooperative thread pool and only assign results back on the main actor once complete.
Mistake: Using @StateObject instead of @State with an @Observable model.@StateObject expects a type conforming to ObservableObject. When you use it with an @Observable type, it may compile (because @Observable does not conflict at the type level), but SwiftUI will use the ObservableObject observation path instead of the new @Observable path — you lose fine-grained tracking and may see unexpected full-view re-renders. Use @State for @Observable models owned by a view, and pass them down as regular properties or through @Environment.
Key Takeaways
@ObservablereplacesObservableObject+@Publishedwith zero-annotation, fine-grained SwiftUI observation — but it does not provide actor isolation;@MainActoris still required for thread safety.- Use
@State(not@StateObject) to own an@Observablemodel in a SwiftUI view;@StateObjectis for the olderObservableObjectprotocol. - Mark methods
nonisolatedwhen they do not access isolated state and should run off the main thread — this keeps the main actor free for UI work while background computation or I/O is in progress.
Last updated: June 27, 2026