@MainActor view models

Concurrency Mid-level Swift 6.0+ iOS 17+ Reviewed By Alok Singh

Spot the bug

This compiles, but the feed sometimes shows glitches and Thread Sanitizer reports a data race. Tap the line where UI state is changed off the main actor.

  1. @Observable
  2. final class FeedModel {
  3. var posts: [Post] = []
  4. func refresh() {
  5. Task.detached {
  6. let new = try await API.posts()
  7. self.posts = new
  8. }
  9. }
  10. }
Hint 1 of 2

Which line changes state the UI observes?

Hint 2 of 2

Where does a detached task run?

Choose an answer, then open Show answer below to check it.

Show answer
  • Answer: line 7 self.posts = new

Line 7 sets observed UI state from a detached task on the concurrent pool while SwiftUI reads it on the main thread. Mark the class @MainActor and use Task {} so the assignment happens on the main actor; then the compiler checks every access.

30-second answer

A view model's state drives the UI, and UI state must only change on the main thread. Marking the class @MainActor makes the compiler enforce that: every property and method is main-actor isolated, and slow work runs in async calls that suspend instead of blocking. In Xcode 26 app targets, MainActor is the default isolation.

Practise saying it · 45 s. No recording, just a timer

Explain why view models should be @MainActor.

Say your answer out loud, then check which of these points you covered:

  • UI state changes only on main
  • Compiler enforces it
  • await suspends, doesn't block
  • Heavy work goes to @concurrent functions

Deep dive

Without isolation, a background task can mutate observed state and cause purple runtime warnings or crashes.

await inside a main-actor method suspends; the UI stays responsive while the network call runs.

Move CPU-heavy work into @concurrent functions or a separate actor.

Swift 6 turns many of these bugs into compile errors.

Fixed Type-checked · Swift 6.4
@MainActor @Observable
final class FeedModel {
    var posts: [Post] = []
    func refresh() async {
        do { posts = try await API.posts() }
        catch { /* show error state */ }
    }
}

Common mistakes

  • Thinking @MainActor makes network calls run on the main thread.
  • Using DispatchQueue.main.async inside async code.
  • Marking only some methods @MainActor and leaving properties unprotected.

Senior take & follow-ups

Seniors separate 'where state lives' (main actor) from 'where heavy work runs' (concurrent functions) instead of sprinkling hops.

Follow-up questions

Think of your answer first, then open the hint.

  1. Does an await inside a @MainActor method block the main thread?

    Show hint

    No; it suspends and frees the thread.

  2. What changes with default MainActor isolation in Swift 6.2?

    Show hint

    Types are main-actor unless you opt out with nonisolated or @concurrent.

Suggest a fix if something here is wrong or out of date.