Is there a way to update Observable on the main thread but not to read from it?

I've been obsessed with this topic for the past couple of weeks and unfortunately there just isn't a good answer out there even from the community. Therefore I am hoping that I can summon Quinn to get an official Apple position (on what's seemingly a fairly fundamental part of using SwiftUI).

Consider this simple example:

import Foundation

@MainActor
@Observable
class UserViewModel {
    var name: String = "John Doe"
    var age: Int = 30
    // other properties and logic
}

// NetworkManager does not need to update the UI but needs to read/write from UserViewModel.
class NetworkManager {
    func updateUserInfo(viewModel: UserViewModel) {
        Task {
            // Read values from UserViewModel prior to making a network call
            let userName: String
            let userAge: Int
            
            // Even for a simple read, we have to jump onto the main thread
            await MainActor.run {
                userName = viewModel.name
                userAge = viewModel.age
            }

            // Now perform network call with the retrieved values
            print("Making network call with userName: \(userName) and userAge: \(userAge)")
            
            // Simulate network delay
            try await Task.sleep(nanoseconds: 1_000_000_000)
            
            // After the network call, we update the values, again on the main thread
            await MainActor.run {
                viewModel.name = "Jane Doe"
                viewModel.age = 31
            }
        }
    }
}

// Example usage
let viewModel = UserViewModel()
let networkManager = NetworkManager()

// Calling from some background thread or task
Task {
    await networkManager.updateUserInfo(viewModel: viewModel)
}

In this example, we can see a few things

  • The ViewModel is a class that manages states centrally
  • It needs to be marked as MainActor to ensure that updating of the states is done on the main thread (this is similar to updating @Published in the old days). I know this isn't officially documented in Apple's documentation. But I've seen this mentioned many times to be recommended approach including www.youtub_.com/watch?v=4dQOnNYjO58 and here also I have observed crashes myself when I don't follow this practise

Now so far so good, IF we assume that ViewModel are only in service to Views. The problem comes when the states need to be accessed outside of Views.

in this example, NetworkManager is some random background code that also needs to read/write centralized states. In this case it becomes extremely cumbersome. You'd have to jump to mainthread for each write (which ok - maybe that's not often) but you'd also have to do that for every read.

Now. it gets even more cumbersome if the VM holds a state that is a model object, mentioned in this thread..

Consider this example (which I think is what @Stokestack is referring to)

import Foundation

// UserModel represents the user information
@MainActor // Ensuring the model's properties are accessed from the main thread
class UserModel {
    var name: String
    var age: Int

    init(name: String, age: Int) {
        self.name = name
        self.age = age
    }
}

@MainActor
@Observable
class UserViewModel {
    var userModel: UserModel

    init(userModel: UserModel) {
        self.userModel = userModel
    }
}

// NetworkManager does not need to update the UI but needs to read/write UserModel inside UserViewModel.
class NetworkManager {
    func updateUserInfo(viewModel: UserViewModel) {
        Task {
            // Read values from UserModel before making a network call
            let userName: String
            let userAge: Int
            
            // Jumping to the main thread to safely read UserModel properties
            await MainActor.run {
                userName = viewModel.userModel.name
                userAge = viewModel.userModel.age
            }

            // Simulate a network call
            print("Making network call with userName: \(userName) and userAge: \(userAge)")
            try await Task.sleep(nanoseconds: 1_000_000_000)

            // After the network call, updating UserModel (again, on the main thread)
            await MainActor.run {
                viewModel.userModel.name = "Jane Doe"
                viewModel.userModel.age = 31
            }
        }
    }
}

// Example usage
let userModel = UserModel(name: "John Doe", age: 30)
let viewModel = UserViewModel(userModel: userModel)
let networkManager = NetworkManager()

// Calling from a background thread
Task {
    await networkManager.updateUserInfo(viewModel: viewModel)
}

Now I'm not sure the problem he is referring still exists (because I've tried and indeed you can make codeable/decodables marked as @Mainactor) but it's really messy.

Also, I use SwiftData and I have to imagine that @Model basically marks the class as @MainActor for these reasons.

And finally, what is the official Apple's recommended approach? Clearly Apple created @Observable to hold states of some kind that drives UI. But how do you work with this state in the background?

Now I've asked chatgpt if there is a hack we can do to alleviate this problem by making write on the mainthreads but allow read to be from any thread. It came up with this solution:

import Foundation

class UserModel {
    private var _name: String
    private var _age: Int
    private let queue = DispatchQueue.main // Queue to ensure writes happen on the main thread

    init(name: String, age: Int) {
        self._name = name
        self._age = age
    }

    // Custom getter allows read access from any thread
    var name: String {
        return _name
    }
    
    var age: Int {
        return _age
    }

    // Custom setter ensures writes happen on the main thread
    func update(name: String, age: Int) {
        if Thread.isMainThread {
            self._name = name
            self._age = age
        } else {
            queue.async {
                self._name = name
                self._age = age
            }
        }
    }
}

@Observable
class UserViewModel {
    private var _userModel: UserModel
    private let queue = DispatchQueue.main

    init(userModel: UserModel) {
        self._userModel = userModel
    }

    // Custom getter for the UserModel allows reads from any thread
    var userModel: UserModel {
        return _userModel
    }

    // Custom setter ensures writes happen on the main thread
    func updateUserModel(name: String, age: Int) {
        if Thread.isMainThread {
            _userModel.update(name: name, age: age)
        } else {
            queue.async {
                self._userModel.update(name: name, age: age)
            }
        }
    }
}

// NetworkManager can now read from any thread and only needs to jump to the main thread for writes
class NetworkManager {
    func updateUserInfo(viewModel: UserViewModel) {
        Task {
            // Read values from UserModel without jumping to the main thread
            let userName = viewModel.userModel.name
            let userAge = viewModel.userModel.age

            print("Making network call with userName: \(userName) and userAge: \(userAge)")

            // Simulate network delay
            try await Task.sleep(nanoseconds: 1_000_000_000)

            // After the network call, update the UserModel on the main thread
            viewModel.updateUserModel(name: "Jane Doe", age: 31)
        }
    }
}

// Example usage
let userModel = UserModel(name: "John Doe", age: 30)
let viewModel = UserViewModel(userModel: userModel)
let networkManager = NetworkManager()

// Calling from a background thread
Task {
    await networkManager.updateUserInfo(viewModel: viewModel)
}

Essentially wrapping each state with an internal backing and then controlling read and write. Does this approach have any issues? I dont trust chatgpt.

I dont trust chatgpt.

Likewise. I can spot at least 5 different things I don’t like about that code )-:

And finally, what is the Apple's recommended approach?

I don’t think Apple has an officially recommended approach. Historically we’ve considered app architecture to be the domain of the app developer. SwiftUI has changed that a little, but only a little. There are still many ways to structure your app’s internals that satisfy the constraints imposed by SwiftUI.

Still, I’m not sure I fully understand the ‘ask’ here. To my mind, there needs to be one source of truth for any given state item:

  • If you choose to store that source of truth in a main actor bound object then stuff that’s not main actor bound will need to bounce to the main actor to get it.

  • If you choose to keep that source of truth elsewhere, then it might be more convenient for your code that’s not main actor bound but you’ll need to sync between that source of truth and your view model.

Which is better? It depends on what the state item is and how your app uses it. Indeed, within an app it might make sense to use different approaches for different pieces of state.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Thanks Quinn for chiming in. You are right, app architecture is not Apple's responsibility. I guess we just need to know the intended usage of the the API. So to that end, I guess the 'ask' is the following clarifications:

  1. Are we suppose to update properties of a @Observable on the main thread?
  2. Are we suppose to properties of an object that is a property of Observable on the main thread?
  3. Is it safe to read properties of a @Observable on a background thread?
  4. Is it safe to read properties of an object that is a property of @Observable on a background thread?
  5. Is there a way to enforce check at compile time for setting properties of @Observable (for example by decorating it with @MainActor), but at the same time allow read to be not on the main thread?

You’re talking a lot about threads here, which isn’t ideal when it comes to Swift concurrency. That’s because the thread isn’t key; rather, it’s the isolation context that’s visible to the compiler.

So, let’s look at your last question through that lens:

Is there a way to enforce check at compile time for setting properties of @Observable (for example by decorating it with @MainActor), but at the same time allow read to be not on the main thread?

No. Because those are mutually contradictory requirements. If a property is isolated to the main actor then you can’t access it from a non-isolated context. That’s a fundamental precept of Swift concurrency.

Now, you can write extra code to make this work, but that brings you to the ‘syncing’ problem I discussed in my previous post. For example:

@MainActor
class MyModel {
    var userName: String = "" {
        didSet {
            self._userNameForAll.withLock { $0 = self.userName }
        }
    }

    private let _userNameForAll: Mutex<String> = .init("")

    nonisolated var userNameForAll: String {
        self._userNameForAll.withLock { $0 }
    }
}

This maintains the source of truth in the main-actor-isolated property and syncs it to the non-isolated read-only property.

Keep in mind that you only need to do this if a bounce to the main actor is prohibitively expensive. In most cases you can skip all this and have the non-isolated code can just do an await model.userName.

The thing to watch out for is multiple bounces. If there’s a userName and an accessToken property, you don’t want to bounce to the main actor for each one. And that brings you to another concept that you see a lot in concurrent code: snapshots. If the non-isolated code is performing a transient operation, you can either:

  • Start it with a snapshot of the state to operate on

  • Or create infrastructure for it to fetch such a snapshot

And a snapshot can be something as simple as a sendable struct:

struct MyModelSnapshot: Sendable {
    var userName: String
    var accessToken: Data
}

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

@DTS Engineer Thanks for the explanation! That explains a lot but I guess two questions remain:

  1. When we update a property of @Observable do we have to isolate to the main actor?
  2. When we update the properties of an object that is a property of Observable do we have to isolate to the main actor?

As far as Observation framework is concerned, the answer to both of those is “No.” If you look at its core primitive, the withObservationTracking(_:onChange:) method, the onChange closure has to be sendable. That shows that the observer has no control over the context making the change.

We’ve actually been talking about this recently over on this thread.

SwiftUI may impose further restrictions on that front, but I’m not a SwiftUI expert and thus I can’t say either way.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Is there a way to update Observable on the main thread but not to read from it?
 
 
Q