How to correctly update the UI from an asynchronous context

I'm having a hard time figuring out the correct way to jump to the main thread in order to do UI stuff in an async context. From my understanding, there are (at least) three different ways of doing it, but I'm not sure which is better and why.

Let's say you have a View that contains an observable ViewModel. This ViewModel contains a state enum that describes the current state of the view.
Updating this state will always trigger some UI work so updating the state should be done on the main thread.

So for instance, let's say we want to fetch a list of fruits from an API and display a list containing the result from said API. We also want to show a loading view while our network call isn't finished:

struct MyView: View {
  private let vm: ViewModel

  var body: some View {
    switch vm.state { ... } // Draw what you want based on the state
  }
}

@Observable
final class ViewModel {
  private(set) var state: State = .loading
}

extension ViewModel {
  enum State {
    case loading
    case loaded([Fruit])
    case error(Error)
  }
}

What are the differences between the 3 following pieces of code and which one should be used for what purpose ?

// 1
extension ViewModel {
  func fetch() async {
    await MainActor.run { state = .loading }
    let fruits = await getRemoteFruits() // network
    await MainActor.run { state = .loaded(fruits) }
  }
}

// 2
extension ViewModel {
  func fetch() async {
    Task { @MainActor in state = .loading }
    let fruits = await getRemoteFruits() // network
    Task { @MainActor in state = .loaded(fruits) }
  }
}

// 3
extension ViewModel {
  func fetch() async {
    await loading()
    let fruits = await getRemoteFruits() // network
    await loaded(fruits)
  }

  @MainActor
  private func loading() async { state = .loading }

  @MainActor
  private func loaded(_ fruits: [Fruit]) async { state = .loaded(fruits) }
}

From what I understand, the main difference is that MainActor.run has a synchronous closure while Task { @MainActor in } does not, which might be useful in some cases.

However is there any difference between solution 1 and 3 ?

1 Like

This does not answer your question, but could it be possible that the first task has not finished its computation by the time the fruits become available?

I'd mark ViewModel with @MainActor.

I'd also expose fetch as a sync API for simplicity of using it from the view.

    func fetch() {
        Task {
            state = .loading
            let fruits = await getRemoteFruits()
            state = .loaded(fruits)
        }
    }

Note that the actual networking of getRemoteFruits won't use main actor anyway (if that's a concern):

func getRemoteFruits() async -> [Fruit] {
    let url: URL = ...
    // we may be on the main actor at this point but it doesn't matter
    // the actual networking won't be done on the main actor
    let data = try! await URLSession.shared.data(from: url).0
    // we are not on the main actor at this point
    // convert data to fruits, can take some time or CPU
    return ...
}

That's right albeit unlikely, I don't see any reason preventing this from happening. I guess this solution should be avoided if I don't use the async context

That's a solution, however I find it confusing and harder to "read" from an outside perspective.
This is not the case but by reading the code, you could be tempted to think that everything (network included) will run on the MainActor, which can be confusing imo

Well, that's not just a solution, this is the solution:

  1. Apple officially recommends to mark observable types to be main actor isolated. And despite being too generalised rule, its a good rule - your UI code (especially in ViewModel, which btw redundant in SwiftUI) should run on main actor. And you can now ensure that your updates are safe.
  2. That is the approach in Swift Concurrency in general: your async code explicitly states as part of what isolation domain it is running. And each await is a suspension point and a potential leave of the isolation. You can look at type/function/method declaration and see which isolation (or no isolation) it is has. And non-isolated code always runs on generic executor since Swift 5.7.

So, with a bit of understanding of how concurrency works in Swift, it shouldn't be confusing at all. Yet even without that understanding, awaiting on some function call already means that it is (more likely) being executed in different isolation.

2 Likes

First two are unstructured and think there is no guarantee in execution. Third one is best of them.

In the first case, aren't you guaranteed order of execution because of the suspension points ?

Within the difference, creation of unstructured tasks (even though they will end up to run on the same actor), is not advised, because you are loosing flow control of this fire-and-forget tasks. In your exact case it won't run in arbitrary order, but it is easy to eventually call it not from the main actor and the state updates might execute differently.

Explicitly awaiting everything on main actor as in 1st case is also a subject of errors: too easy to forget. That is why global actor isolation is handy.

3rd one has the same issue as 1st, since you have to remember to mark these methods to be main actor isolated.

2 Likes

Ah, true, somehow thought there is a Task there.
Though marking @MainActor instead of using MainActor.run {} is a better option as @vns points out.

1 Like

It makes sense, thank you for the explanation.

However, since the fetch method is invoked from the view, shouldn't it be marked as nonisolated and thus defeating the purpose of marking the ViewModel as @MainActor ?

No, it shouldn’t be nonisolated. Your state property directly affects view, so it should be isolated and updated from the main actor. And by marking view model isolated on main actor you now ensure that all UI updates will happen on the main actor. The remote loading which is more likely will be done via URLSession object is already nonisolated (URLSession itself isn’t part of any actor) so the remote call will happen in non-blocking way leaving main actor isolation.

You can also make getRemoteFruits to be nonisolated if they are part of the view model and you want to ensure 100% they won’t run as part of main actor, in that case you will need Fruit to be Sendable as it will cross isolation boundary from non-isolated code to main-actor isolated.

1 Like

My bad, you still need to invoke the method in an asynchronous context, I was confused since the fetch function isn't marked async anymore.

This was very helpful, thank you!

1 Like

Can you please explain why is the behaviour so or point to any resource with more detail?

I was concerned that the API request will be executed on the main thread (and potentially freeze the UI).

The key for understanding right now is this change:

In general, it defines that all non-isolated code (freestanding async functions or nonisolated async methods) is guaranteed to be executed on a generic executor. That’s enough for you to be sure that nonisolated won’t be running on main actor and block it. URLSession.data method falls into that category of being nonisolated.

For more complete understanding, I recommend reading this proposal in details, as it covers topic more comprehensively.

2 Likes