Async/Await and updating state

When using conformance to ObservableObject and then doing async work in a Task, you will get a warning courtesy of Combine if you then update an @Published or @State var from anywhere but the main thread. However, if you are using @Observable there is no such warning. Also, Thread.current is unavailable in asynchronous contexts, so says the warning. And I have read that in a sense you simply aren't concerned with what thread an async task is on.

So for me, that begs a question. Is the lack of a warning, which when using Combine is rather important as ignoring it could lead to crashes, a pretty major bug that Apple seemingly should have addressed long ago? Or is it just not an issue to update state from another thread, because Xcode is doing that work for us behind the scenes too, just as it manages what thread the async task is running on when we don't specify?

I see a lot of posts about this from around the initial release of Async/Await talking about using await MainActor.run {} at the point the state variable is updated, usually also complaining about the lack of a warning. But ow years later there is still no warning and I have to wonder if this is actually a non issue. On some ways similar to the fact that many of the early posts I have seen related to @Observable have examples of an @Observable ViewModel instantiated in the view as an @State variable, but in fact this is not needed as that is addressed behind the scenes for all properties of an @Observable type.

At least, that is my understanding now, but I am learning Swift coming from a PowerShell background so I question my understanding a lot.

For what it's worth, I did find this thread, which offers a lot of insight and even seems to suggest a definitive answer as to a best practice, but again this is over 2 years ago. In that 25 months I would think Apple would fix the bug and we would be getting some sort of feedback when revising state from an async context, if indeed doing so was still an issue. That said, I think I am going to refactor a few things to use approach #3 from that thread, just to be on the safe side and develop good habits. But I still wonder if in fact this isn't necessary. And, if it IS necessary, and we no longer get any feedback from Xcode when we fail, I wonder what other issues I need to be aware of that Xcode isn't going to draw my attention to?

I have also seen it mentioned that @Observable and @MainActor can not be used together, so the old approach of just putting your whole ObservableObject ViewModel on the main actor no longer works. But I just verified that at least as of Xcode 16.1 I can use them both, and when I do my otherwise unmanaged State updates all occur on the main thread, while removing @MainActor on the VM causes all those state updates to occur on background threads. Again with no warning and no crashes. Yet. :)

So you are combining [hey hey!] a bunch of different things here, including:

  • Tasks and threads

  • Combine

  • Observation

  • SwiftUI

It’s hard to know which of these are critical to your goal and which are things you’ve bumped into while trying to find a solution. My general advice is:

  • Stick with Swift concurrency as much as possible.

  • If you’re working with Swift concurrency, don’t do anything with threads (or Dispatch queues for that matter). Combining the two is possible, but it’s easy to get it wrong [1].

  • Pick an observation mechanism, Combine or Observation, and try to stick with it as much as possible.

  • If you’re using Observation and SwiftUI, make your observable models @MainActor.

If you can post details about a specific problems you’re trying to solve, I’d be happy to offer more specific advice.

Share and Enjoy

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

[1] This is an area that I’ve investigated in depth, so I’d be more than happy to answer questions about specific cases.

Pick an observation mechanism, Combine or Observation, and try to stick with it as much as possible.

@DTS Engineer hi! You didn‘t mention AsyncStream here, is this intentional, or could it also be considered an observation mechanism?

tl;dr

The Observation framework does not require isolating your @Observable objects to the main actor. Rather than assuming that the lack of a warning message is a bug, we can be pretty confident that the main thread checker is doing its job and no isolation of the main actor is needed.


A few observations:

  1. Yes, when using ObservableObject, WWDC videos repeatedly advised isolating this to the main actor, to make sure that UI updates happen on the main thread.

  2. You are correct that with @Observable with SwiftUI, the WWDC videos and official documentation lack formal assurances that explicit isolation to the main actor is no longer needed, but experience has confirmed this to be the case. But, they certainly haven’t warned us that we should isolate it to the main actor, like they did with ObservableObject.

    While I agree that greater clarity would be appreciated, I do not share your opinion that this means that this means that “it could lead to crashes” nor that it is an unaddressed “pretty major bug”. It feels like a deficiency in the documentation, more than anything else.

    To use your turn of phrase, I think this is a “non issue”: With SwiftUI, you do not have to isolate the @Observable to the main actor. (You may want to for other reasons; see point 6, below.)

    For what it is worth, while Apple’s documentation is sorely silent on this point, the fact that main actor isolation is not required has been discussed by others (e.g., https://www.sobyte.net/post/2023-08/observation-framework).

  3. As an aside, you said:

    I see a lot of posts about this from around the initial release of Async/Await talking about using await MainActor.run {} at the point the state variable is updated.

    Yes, there were a lot of those. I think those stemmed from developers who were used to the DispatchQueue.main.async {…} pattern.

    But, IMHO, that is code smell. If the property is properly isolated to the main actor, this isn’t needed. In fact, if you see WWDC 2019 Swift concurrency: Update a sample app, they demonstrate the transition from GCD to MainActor.run {…} and finally to just isolate to the correct actor, rendering MainActor.run unnecessary.

  4. Also a bit tangential to the question at hand, but you said:

    On some ways similar to the fact that many of the early posts I have seen related to @Observable have examples of an @Observable ViewModel instantiated in the view as an @State variable, but in fact this is not needed as that is addressed behind the scenes for all properties of an @Observable type.

    I am not sure if I follow you. If you have an @Observable class, you would generally declare that as a @State property in the View. (Yes, we don’t need @StateObject any more, a view generally would store the view model in a @State property.)

  5. You said:

    Also, Thread.current is unavailable in asynchronous contexts, so says the warning. And I have read that in a sense you simply aren't concerned with what thread an async task is on.

    The retirement of Thread.current has nothing to do with this current topic. It is not available from Swift concurrency because it could lead one to draw incorrect conclusions. If you really care about the threading model underpinning Swift concurrency, I would suggest watching WWDC 2019 Swift concurrency: Behind the scenes. But just because the Thread API is not available from Swift concurrency, it does not mean that we do not care about making sure we have the right actor isolation. It just means that we should not dwell on threads, per se.

  6. Now, let’s come back to the @Observable object that has some Task updating some observed property. As soon as you change the “Strict concurrency checking” build setting to “Complete” and/or adopt Swift 6, you will quickly realize that the compiler will complain if it is unable to reason about your object’s thread-safety. (And please, avoid the @unchecked Sendable trick to silence really meaningful warnings; only use that if you cannot use actors or value types to ensure thread safety and have, instead, used some legacy synchronization mechanism to manually implement thread-safety.) Using actor-isolation in your @Observable object is the modern and easiest way to achieve thread-safety for mutable types.

    This is a long winded way of saying that just because @Observable doesn’t require isolation to the main actor, that you might not choose to do so, anyway. You can isolate your @Observable class to any global actor for reasons of thread-safety, and often the main actor is a fine choice.

    So, if you really are afraid that the absence of warnings stems from a bug in the main thread checker and that this might cause problems (a concern I do not share), then just isolate this @Observable object to the main actor and call it a day. You will generally want to isolate it to a global actor, anyway, and the main actor is generally an adequate solution (as long as you never do anything slow and synchronous directly from the main actor). Personally, for an object driving the UI, I would generally isolate it to the main actor for thread-safety reasons, anyway.

Async/Await and updating state
 
 
Q